snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
{
"name": "@blocksuite/affine-widget-drag-handle",
"description": "Drag handle for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-callout": "workspace:*",
"@blocksuite/affine-block-embed": "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-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.23"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,78 @@
import { PlusIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import type { AFFINE_ADD_BLOCK_WIDGET } from '../consts.js';
export class AffineAddBlockWidget extends LitElement {
static override styles = css`
:host {
display: block;
pointer-events: none;
}
.affine-add-block-widget {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-top: 8px;
cursor: pointer;
border-radius: 4px;
color: var(--affine-placeholder-color);
background: transparent;
border: none;
padding: 0;
transition:
color 0.2s ease,
background 0.2s ease;
pointer-events: auto;
user-select: none;
box-sizing: border-box;
}
.affine-add-block-widget:hover {
background: var(--affine-hover-color);
color: var(--affine-text-primary-color);
}
.affine-add-block-widget svg {
width: 12px;
height: 12px;
flex-shrink: 0;
}
`;
@property({ type: Boolean })
accessor visible = false;
private readonly _handleClick = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
this.dispatchEvent(
new CustomEvent('add-block', { bubbles: true, composed: true })
);
};
override render() {
if (!this.visible) return html``;
return html`
<button
class="affine-add-block-widget"
title="Click to add a block below"
aria-label="Add block below"
@click=${this._handleClick}
>
${PlusIcon({ width: '12', height: '12' })}
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_ADD_BLOCK_WIDGET]: AffineAddBlockWidget;
}
}

View File

@@ -0,0 +1,133 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
EmbedIcon,
FrameIcon,
ImageIcon,
PageIcon,
ShapeIcon,
} from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
const BLOCK_PREVIEW_ICON_MAP: Record<
string,
{
icon: typeof ShapeIcon;
name: string;
}
> = {
shape: {
icon: ShapeIcon,
name: 'Edgeless shape',
},
'affine:image': {
icon: ImageIcon,
name: 'Image block',
},
'affine:note': {
icon: PageIcon,
name: 'Note block',
},
'affine:frame': {
icon: FrameIcon,
name: 'Frame block',
},
'affine:embed-': {
icon: EmbedIcon,
name: 'Embed block',
},
};
declare global {
interface HTMLElementTagNameMap {
'edgeless-dnd-preview-element': EdgelessDndPreviewElement;
}
}
export const EDGELESS_DND_PREVIEW_ELEMENT = 'edgeless-dnd-preview-element';
export class EdgelessDndPreviewElement extends LitElement {
static override styles = css`
.edgeless-dnd-preview-container {
position: relative;
padding: 12px;
width: 264px;
height: 80px;
}
.edgeless-dnd-preview-block {
display: flex;
position: absolute;
width: 234px;
align-items: flex-start;
box-sizing: border-box;
border-radius: 8px;
background-color: ${unsafeCSSVarV2(
'layer/background/overlayPanel',
'#FBFBFC'
)};
padding: 8px 20px;
gap: 8px;
transform-origin: center;
font-family: var(--affine-font-family);
box-shadow: 0px 0px 0px 0.5px #e3e3e4 inset;
}
.edgeless-dnd-preview-block > svg {
color: ${unsafeCSSVarV2('icon/primary', '#77757D')};
}
.edgeless-dnd-preview-block > .text {
color: ${unsafeCSSVarV2('text/primary', '#121212')};
font-size: 14px;
line-height: 24px;
}
`;
@property({ type: Array })
accessor elementTypes: {
type: string;
}[] = [];
private _getPreviewIcon(type: string) {
if (BLOCK_PREVIEW_ICON_MAP[type]) {
return BLOCK_PREVIEW_ICON_MAP[type];
}
if (type.startsWith('affine:embed-')) {
return BLOCK_PREVIEW_ICON_MAP['affine:embed-'];
}
return {
icon: ShapeIcon,
name: 'Edgeless content',
};
}
override render() {
const blocks = repeat(this.elementTypes.slice(0, 3), ({ type }, index) => {
const { icon, name } = this._getPreviewIcon(type);
return html`<div
class="edgeless-dnd-preview-block"
style=${styleMap({
transform: `rotate(${index * -2}deg)`,
zIndex: 3 - index,
})}
>
${icon({ width: '24px', height: '24px' })}
<span class="text">${name}</span>
</div>`;
});
return html`<div class="edgeless-dnd-preview-container">${blocks}</div>`;
}
}

View File

@@ -0,0 +1,20 @@
export const ADD_BLOCK_WIDGET_WIDTH = 16;
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;

View File

@@ -0,0 +1,2 @@
export const AFFINE_DRAG_HANDLE_WIDGET = 'affine-drag-handle-widget';
export const AFFINE_ADD_BLOCK_WIDGET = 'affine-add-block-widget';

View File

@@ -0,0 +1,311 @@
import './components/add-block-widget.js';
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type { RootBlockModel } from '@blocksuite/affine-model';
import { focusTextModel } from '@blocksuite/affine-rich-text';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
isInsideEdgelessEditor,
isInsidePageEditor,
} from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import type { IVec, Point, Rect } from '@blocksuite/global/gfx';
import { type BlockComponent, WidgetComponent } from '@blocksuite/std';
import type { GfxModel } from '@blocksuite/std/gfx';
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';
//focustextmodel rich text should be added in package.json file and import from there
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.isDragHandleHovered = false;
this.pointerEventWatcher.reset();
};
/**
* Insert a new empty paragraph block below the currently hovered block
* and move the cursor into it.
*/
private readonly _handleAddBlock = () => {
const anchorBlockId = this.anchorBlockId.peek();
if (!anchorBlockId) return;
const block = this.anchorBlockComponent.peek();
if (!block) return;
const { store } = this;
const parent = store.getParent(block.model);
if (!parent) return;
const index = parent.children.indexOf(block.model);
if (index < 0) return;
store.captureSync();
const newBlockId = store.addBlock(
'affine:paragraph',
{},
parent,
index + 1
);
if (!newBlockId) return;
this.host.updateComplete
.then(() => {
focusTextModel(this.std, newBlockId);
})
.catch(console.error);
this.hide();
};
@state()
accessor activeDragHandle: 'block' | 'gfx' | null = null;
@state()
accessor showAddBlockWidget = false;
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.deleted.subscribe(() => this.hide())
);
};
/**
* @param force Reset the dragging state
*/
hide = (force = false) => {
if (this.dragging && !force) return;
updateDragHandleClassName();
this.isDragHandleHovered = false;
this.anchorBlockId.value = null;
this.dragHoverRect = null;
this.activeDragHandle = null;
this.showAddBlockWidget = false;
if (this.dragHandleContainer) {
this.dragHandleContainer.removeAttribute('style');
this.dragHandleContainer.style.display = 'none';
}
if (this.dragHandleGrabber) {
this.dragHandleGrabber.removeAttribute('style');
}
if (this.addBlockWidgetContainer) {
this.addBlockWidgetContainer.removeAttribute('style');
this.addBlockWidgetContainer.style.display = 'none';
}
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-add-block-widget-container">
<affine-add-block-widget
.visible=${this.showAddBlockWidget && this.mode === 'page'}
@add-block=${this._handleAddBlock}
></affine-add-block-widget>
</div>
<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;
@query('.affine-add-block-widget-container')
accessor addBlockWidgetContainer!: HTMLDivElement;
@state()
accessor dragHoverRect: {
width: number;
height: number;
left: number;
top: number;
} | null = null;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_DRAG_HANDLE_WIDGET]: AffineDragHandleWidget;
}
}

View File

@@ -0,0 +1,16 @@
import { AffineAddBlockWidget } from './components/add-block-widget';
import {
EDGELESS_DND_PREVIEW_ELEMENT,
EdgelessDndPreviewElement,
} from './components/edgeless-preview/preview';
import { AFFINE_ADD_BLOCK_WIDGET, AFFINE_DRAG_HANDLE_WIDGET } from './consts';
import { AffineDragHandleWidget } from './drag-handle';
export function effects() {
customElements.define(AFFINE_DRAG_HANDLE_WIDGET, AffineDragHandleWidget);
customElements.define(AFFINE_ADD_BLOCK_WIDGET, AffineAddBlockWidget);
customElements.define(
EDGELESS_DND_PREVIEW_ELEMENT,
EdgelessDndPreviewElement
);
}

View File

@@ -0,0 +1,208 @@
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine-ext-loader';
import {
DocModeExtension,
DocModeProvider,
EditorSettingExtension,
EditorSettingProvider,
} from '@blocksuite/affine-shared/services';
import { BlockStdScope, BlockViewIdentifier } from '@blocksuite/std';
import type {
BlockModel,
BlockViewType,
ExtensionType,
Query,
SliceSnapshot,
} from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { literal } from 'lit/static-html.js';
import { EdgelessDndPreviewElement } from '../components/edgeless-preview/preview.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
export class PreviewHelper {
private readonly _calculateQuery = (selectedIds: string[]): 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.store.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.store.getBlock(id)?.model;
if (!model) {
return;
}
const children = model.children ?? [];
children.forEach(child => {
ids.push({ viewType: 'display', id: child.id });
addChildren(child.id);
});
};
selectedIds.forEach(addChildren);
return {
match: ids,
mode: 'strict',
};
};
getPreviewStd = (blockIds: string[]) => {
const widget = this.widget;
const std = widget.std;
blockIds = blockIds.slice();
const docModeService = std.get(DocModeProvider);
const editorSetting = std.get(EditorSettingProvider);
const query = this._calculateQuery(blockIds as string[]);
const store = widget.store.doc.getStore({ query });
let previewSpec = widget.std
.get(ViewExtensionManagerIdentifier)
.get('preview-page');
const settingSignal = signal({ ...editorSetting.setting$.peek() });
const extensions = [
DocModeExtension(docModeService),
EditorSettingExtension({ setting$: 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.store.getParent(model.id);
if (parent?.flavour === 'affine:surface') {
return literal`affine-edgeless-placeholder-preview-image`;
}
return literal`affine-placeholder-preview-image`;
};
});
},
} as ExtensionType,
];
previewSpec = previewSpec.concat(extensions);
settingSignal.value = {
...settingSignal.value,
edgelessDisableScheduleUpdate: true,
};
const previewStd = new BlockStdScope({
store,
extensions: previewSpec,
});
let width: number = 500;
// oxlint-disable-next-line no-unassigned-vars
let height;
const noteBlock = this.widget.host.querySelector('affine-note');
width = noteBlock?.offsetWidth ?? noteBlock?.clientWidth ?? 500;
return {
previewStd,
width,
height,
};
};
private _extractBlockTypes(snapshot: SliceSnapshot) {
const blockTypes: {
type: string;
}[] = [];
snapshot.content.forEach(block => {
if (block.flavour === 'affine:surface') {
Object.values(
block.props.elements as Record<string, { id: string; type: string }>
).forEach(elem => {
blockTypes.push({
type: elem.type,
});
});
} else {
blockTypes.push({
type: block.flavour,
});
}
});
return blockTypes;
}
getPreviewElement = (options: {
blockIds: string[];
snapshot: SliceSnapshot;
mode: 'block' | 'gfx';
}) => {
const { blockIds, snapshot, mode } = options;
if (mode === 'block') {
const { previewStd, width, height } = this.getPreviewStd(blockIds);
const previewTemplate = previewStd.render();
return {
width,
height,
element: previewTemplate,
};
} else {
const blockTypes = this._extractBlockTypes(snapshot);
const edgelessPreview = new EdgelessDndPreviewElement();
edgelessPreview.elementTypes = blockTypes;
return {
left: 12,
top: 12,
element: edgelessPreview,
};
}
};
renderDragPreview = (options: {
blockIds: string[];
snapshot: SliceSnapshot;
container: HTMLElement;
mode: 'block' | 'gfx';
}): { x: number; y: number } => {
const { container } = options;
const { width, height, element, left, top } =
this.getPreviewElement(options);
container.style.position = 'absolute';
container.style.left = left ? `${left}px` : '';
container.style.top = top ? `${top}px` : '';
container.style.width = width ? `${width}px` : '';
container.style.height = height ? `${height}px` : '';
container.append(element);
return {
x: left ?? 0,
y: top ?? 0,
};
};
constructor(readonly widget: AffineDragHandleWidget) {}
}

View File

@@ -0,0 +1,95 @@
import { getCurrentNativeRange } from '@blocksuite/affine-shared/utils';
import { Rect } from '@blocksuite/global/gfx';
import type { BlockComponent } from '@blocksuite/std';
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) {}
}

View File

@@ -0,0 +1,67 @@
import { findNoteBlockModel } from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
BlockSelection,
SurfaceSelection,
TextSelection,
} from '@blocksuite/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) {}
}

View File

@@ -0,0 +1,15 @@
import { WidgetViewExtension } from '@blocksuite/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)}`
);

View File

@@ -0,0 +1,119 @@
import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import type { IVec, SerializedXYWH } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import type { BlockStdScope } from '@blocksuite/std';
import {
GfxController,
type GfxModel,
isGfxGroupCompatibleModel,
} from '@blocksuite/std/gfx';
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 }) => {
const beforeExportSubscription = slots.beforeExport.subscribe(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;
}
});
const afterExportSubscription = slots.afterExport.subscribe(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]
),
};
}
});
}
});
}
});
return () => {
beforeExportSubscription.unsubscribe();
afterExportSubscription.unsubscribe();
};
};
};

View File

@@ -0,0 +1,56 @@
import {
AttachmentBlockModel,
BookmarkBlockModel,
EmbedGithubModel,
EmbedLinkedDocModel,
NoteBlockModel,
} from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type { TransformerMiddleware } from '@blocksuite/store';
export const cardStyleUpdater =
(std: BlockStdScope): TransformerMiddleware =>
({ slots }) => {
slots.beforeImport.subscribe(payload => {
if (payload.type !== 'block' || !payload.parent) return;
const parentModel = std.store.getModelById(payload.parent);
if (!matchModels(parentModel, [NoteBlockModel])) return;
// TODO(@L-Sun): Refactor this after refactor `store.moveBlocks`
// Currently, drag a block will use store.moveBlocks to update the tree of blocks
// but the instance of it is not changed.
// So change the style of snapshot.props in the middleware is not working.
// Instead, we can change the style of the model instance in the middleware,
const model = std.store.getModelById(payload.snapshot.id);
if (!model) return;
if (model instanceof AttachmentBlockModel) {
std.store.updateBlock(model, {
style: 'horizontalThin',
});
return;
}
if (model instanceof BookmarkBlockModel) {
std.store.updateBlock(model, {
style: 'horizontal',
});
return;
}
if (model instanceof EmbedGithubModel) {
std.store.updateBlock(model, {
style: 'horizontal',
});
return;
}
if (model instanceof EmbedLinkedDocModel) {
std.store.updateBlock(model, {
style: 'horizontal',
});
return;
}
});
};

View File

@@ -0,0 +1,53 @@
import { DatabaseBlockModel } from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type { TransformerMiddleware } from '@blocksuite/store';
export const newIdCrossDoc =
(std: BlockStdScope): TransformerMiddleware =>
({ slots }) => {
let samePage = false;
const oldToNewIdMap = new Map<string, string>();
const beforeImportSliceSubscription = slots.beforeImport.subscribe(
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;
}
}
);
const afterImportBlockSubscription = slots.afterImport.subscribe(
payload => {
if (
!samePage &&
payload.type === 'block' &&
matchModels(payload.model, [DatabaseBlockModel])
) {
const originalCells = payload.model.props.cells;
const newCells = {
...originalCells,
};
Object.keys(originalCells).forEach(cellId => {
if (oldToNewIdMap.has(cellId)) {
newCells[oldToNewIdMap.get(cellId)!] = originalCells[cellId];
}
});
payload.model.props.cells$.value = newCells;
}
}
);
return () => {
beforeImportSliceSubscription.unsubscribe();
afterImportBlockSubscription.unsubscribe();
};
};

View File

@@ -0,0 +1,31 @@
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/std';
import type { TransformerMiddleware } from '@blocksuite/store';
export const reorderList =
(std: BlockStdScope): TransformerMiddleware =>
({ slots }) => {
const afterImportBlockSubscription = slots.afterImport.subscribe(
payload => {
if (payload.type === 'block') {
const model = payload.model;
if (
matchModels(model, [ListBlockModel]) &&
model.props.type === 'numbered'
) {
const next = std.store.getNext(model);
correctNumberedListsOrderToPrev(std.store, model);
if (next) {
correctNumberedListsOrderToPrev(std.store, next);
}
}
}
}
);
return () => {
afterImportBlockSubscription.unsubscribe();
};
};

View File

@@ -0,0 +1,103 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css } from 'lit';
import {
ADD_BLOCK_WIDGET_WIDTH,
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;
pointer-events: none;
}
.affine-add-block-widget-container {
top: 0;
left: 0;
position: absolute;
display: flex;
justify-content: center;
width: ${ADD_BLOCK_WIDGET_WIDTH}px;
min-height: 12px;
pointer-events: none;
user-select: none;
box-sizing: border-box;
}
.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;
}
}
`;

View File

@@ -0,0 +1,346 @@
import { type CalloutBlockComponent } from '@blocksuite/affine-block-callout';
import {
AFFINE_EDGELESS_NOTE,
EdgelessNoteBackground,
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 {
Bound,
Point,
Rect,
type SerializedXYWH,
} from '@blocksuite/global/gfx';
import type { BlockComponent, EditorHost } from '@blocksuite/std';
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).props.type) {
key = (model as ParagraphBlockModel).props.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.store.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.props.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) {
const elements = document.elementsFromPoint(point.x, point.y);
for (const el of elements) {
if (el instanceof EdgelessNoteBlockComponent) {
return el;
}
// When in edit mode for edgeless-note, the rect of note-background is larger than
// that of edgeless-note. Therefore, when the point is located in the area between
// note-background and edgeless-note, using elementsFromPoint alone cannot correctly
// retrieve the edgeless-note.
if (el instanceof EdgelessNoteBackground) {
return el.closest(AFFINE_EDGELESS_NOTE) ?? null;
}
}
return 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;
}

View File

@@ -0,0 +1,21 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { dragHandleWidget } from '.';
import { effects } from './effects';
export class DragHandleViewExtension extends ViewExtensionProvider {
override name = 'affine-drag-handle-widget';
override effect() {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register(dragHandleWidget);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,387 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import { getSelectedRect } from '@blocksuite/affine-shared/utils';
import { type IVec, Rect } from '@blocksuite/global/gfx';
import {
GfxControllerIdentifier,
type ToolOptionWithType,
} from '@blocksuite/std/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';
type HoveredElemArea = {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
padding: number;
containerWidth: number;
};
/**
* 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 _pendingHoveredElemArea: HoveredElemArea | null = null;
private _lastAppliedHoveredElemArea: HoveredElemArea | null = null;
private _showDragHandleRafId: number | null = null;
private _surfaceElementUpdatedRafId: number | null = null;
private readonly _cloneArea = (area: HoveredElemArea): HoveredElemArea => ({
left: area.left,
top: area.top,
right: area.right,
bottom: area.bottom,
width: area.width,
height: area.height,
padding: area.padding,
containerWidth: area.containerWidth,
});
private readonly _isAreaEqual = (
left: HoveredElemArea | null,
right: HoveredElemArea | null
) => {
if (!left || !right) return false;
return (
left.left === right.left &&
left.top === right.top &&
left.right === right.right &&
left.bottom === right.bottom &&
left.width === right.width &&
left.height === right.height &&
left.padding === right.padding &&
left.containerWidth === right.containerWidth
);
};
private readonly _scheduleShowDragHandleFromSurfaceUpdate = () => {
if (this._surfaceElementUpdatedRafId !== null) return;
this._surfaceElementUpdatedRafId = requestAnimationFrame(() => {
this._surfaceElementUpdatedRafId = null;
if (!this.widget.isGfxDragHandleVisible) return;
this._showDragHandle();
});
};
private readonly _handleEdgelessToolUpdated = (
newTool: ToolOptionWithType
) => {
if (newTool.toolType?.toolName === '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) {
const area = this.hoveredElemArea;
this._showDragHandle(area);
this._updateDragHoverRectTopLevelBlock(area);
} else if (this.widget.activeDragHandle) {
this.widget.hide();
}
};
private readonly _flushShowDragHandle = () => {
this._showDragHandleRafId = null;
if (!this.widget.anchorBlockId.peek()) return;
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
const area = this._pendingHoveredElemArea ?? this.hoveredElemArea;
this._pendingHoveredElemArea = null;
if (!area) return;
if (
this.widget.isGfxDragHandleVisible &&
this._isAreaEqual(this._lastAppliedHoveredElemArea, area)
) {
return;
}
if (container.style.transition !== 'none') {
container.style.transition = 'none';
}
const nextPaddingTop = '0px';
if (container.style.paddingTop !== nextPaddingTop) {
container.style.paddingTop = nextPaddingTop;
}
const nextPaddingBottom = '0px';
if (container.style.paddingBottom !== nextPaddingBottom) {
container.style.paddingBottom = nextPaddingBottom;
}
const nextLeft = `${area.left}px`;
if (container.style.left !== nextLeft) {
container.style.left = nextLeft;
}
const nextTop = `${area.top}px`;
if (container.style.top !== nextTop) {
container.style.top = nextTop;
}
if (container.style.display !== 'flex') {
container.style.display = 'flex';
}
this.widget.handleAnchorModelDisposables();
this.widget.activeDragHandle = 'gfx';
this._lastAppliedHoveredElemArea = this._cloneArea(area);
};
private readonly _showDragHandle = (area?: HoveredElemArea | null) => {
const nextArea = area ?? this.hoveredElemArea;
this._pendingHoveredElemArea = nextArea;
if (!this._pendingHoveredElemArea) {
return;
}
if (
this.widget.isGfxDragHandleVisible &&
this._showDragHandleRafId === null &&
this._isAreaEqual(
this._lastAppliedHoveredElemArea,
this._pendingHoveredElemArea
)
) {
return;
}
if (this._showDragHandleRafId !== null) {
return;
}
this._showDragHandleRafId = requestAnimationFrame(
this._flushShowDragHandle
);
};
private readonly _updateDragHoverRectTopLevelBlock = (
area?: HoveredElemArea | null
) => {
if (!this.widget.dragHoverRect) return;
const nextArea = area ?? this.hoveredElemArea;
if (!nextArea) {
this.widget.dragHoverRect = null;
return;
}
const nextRect = new Rect(
nextArea.left,
nextArea.top,
nextArea.right,
nextArea.bottom
);
const prevRect = this.widget.dragHoverRect;
if (
prevRect &&
prevRect.left === nextRect.left &&
prevRect.top === nextRect.top &&
prevRect.width === nextRect.width &&
prevRect.height === nextRect.height
) {
return;
}
this.widget.dragHoverRect = nextRect;
};
get gfx() {
return this.widget.std.get(GfxControllerIdentifier);
}
updateAnchorElement = () => {
if (!this.widget.isConnected) return;
if (this.widget.store.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.store.readonly
) {
this.widget.hide();
return;
}
const selectedElement = selectedElements[0];
this.widget.anchorBlockId.value = selectedElement.id;
this._showDragHandle();
};
get hoveredElemAreaRect() {
const area = this.hoveredElemArea;
if (!area) return null;
return new Rect(area.left, area.top, area.right, area.bottom);
}
get hoveredElemArea(): HoveredElemArea | null {
const edgelessElement = this.widget.anchorEdgelessElement.peek();
if (!edgelessElement) return null;
const { viewport } = this.gfx;
const rect = getSelectedRect([edgelessElement]);
// Compensate for outer CSS scale, matching GfxBlockComponent.getCSSTransform.
const { viewportX, viewportY, viewScale } = viewport;
const scale = this.widget.scale.peek();
let left = ((rect.left - viewportX) * scale) / viewScale;
const top = ((rect.top - viewportY) * scale) / viewScale;
const width = (rect.width * scale) / viewScale;
const height = (rect.height * scale) / viewScale;
let [right, bottom] = [left + width, top + height];
const padding = (HOVER_AREA_RECT_PADDING_TOP_LEVEL * scale) / viewScale;
const containerWidth =
(DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL * scale) / viewScale;
const offsetLeft = DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL / viewScale;
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.subscribe(this._handleEdgelessViewPortUpdated)
);
disposables.add(() => {
if (this._showDragHandleRafId !== null) {
cancelAnimationFrame(this._showDragHandleRafId);
this._showDragHandleRafId = null;
}
if (this._surfaceElementUpdatedRafId !== null) {
cancelAnimationFrame(this._surfaceElementUpdatedRafId);
this._surfaceElementUpdatedRafId = null;
}
this._pendingHoveredElemArea = null;
this._lastAppliedHoveredElemArea = null;
});
disposables.add(
selection.slots.updated.subscribe(() => {
this.updateAnchorElement();
})
);
disposables.add(
edgelessSlots.readonlyUpdated.subscribe(() => {
this.updateAnchorElement();
})
);
disposables.add(
edgelessSlots.elementResizeEnd.subscribe(() => {
this.updateAnchorElement();
})
);
disposables.add(
effect(() => {
const value = tool.currentToolOption$.value;
value && this._handleEdgelessToolUpdated(value);
})
);
disposables.add(
edgelessSlots.elementResizeStart.subscribe(() => {
this.widget.hide();
})
);
disposables.add(
std.store.slots.blockUpdated.subscribe(payload => {
if (
this.widget.isGfxDragHandleVisible &&
payload.id === this.widget.anchorBlockId.peek()
) {
if (payload.type === 'delete') {
this.widget.hide();
}
if (payload.type === 'update') {
this._scheduleShowDragHandleFromSurfaceUpdate();
}
}
})
);
if (surface) {
disposables.add(
surface.elementUpdated.subscribe(({ id }) => {
if (this.widget.isGfxDragHandleVisible) {
if (id !== this.widget.anchorBlockId.peek()) return;
this._scheduleShowDragHandleFromSurfaceUpdate();
}
})
);
}
}
}

View File

@@ -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
);
}
}

View File

@@ -0,0 +1,24 @@
import type { UIEventHandler } from '@blocksuite/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 });
}
}

View File

@@ -0,0 +1,21 @@
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.pageViewportService.subscribe(() => {
this.widget.hide();
})
);
}
}

View File

@@ -0,0 +1,415 @@
import type { NoteBlockComponent } from '@blocksuite/affine-block-note';
import { captureEventTarget } from '@blocksuite/affine-shared/utils';
import { Point } from '@blocksuite/global/gfx';
import {
BLOCK_ID_ATTR,
type BlockComponent,
type PointerEventState,
type UIEventHandler,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { computed } from '@preact/signals-core';
import throttle from 'lodash-es/throttle';
import {
ADD_BLOCK_WIDGET_WIDTH,
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.store.id !== this.widget.store.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;
private _lastPointerHitBlockId: string | null = null;
private _lastPointerHitBlockElement: Element | 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);
if (!this.widget.rootComponent) return;
const closestBlock = getClosestBlockByPoint(
this.widget.host,
this.widget.rootComponent,
point
);
if (!closestBlock) {
this._lastPointerHitBlockId = null;
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.store.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.widget.showAddBlockWidget = true;
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.store.readonly ||
this.widget.dragging ||
!this.widget.isConnected
) {
this.widget.hide();
return;
}
if (this.widget.isGfxDragHandleVisible) return;
const state = ctx.get('pointerState');
const { target } = state.raw;
const element = captureEventTarget(target);
// When pointer not on block or on dragging, should do nothing
if (!element) {
this._lastPointerHitBlockId = null;
this._lastPointerHitBlockElement = null;
return;
}
// When pointer on drag handle or add-block widget, should do nothing
if (
element.closest('.affine-drag-handle-container') ||
element.closest('.affine-add-block-widget-container')
) {
return;
}
if (!this.widget.rootComponent) return;
const hitBlock = element.closest(`[${BLOCK_ID_ATTR}]`);
const hitBlockId = hitBlock?.getAttribute(BLOCK_ID_ATTR) ?? null;
// Pointer move events are high-frequency. If hovered block identity is
// unchanged and the underlying block element is the same, skip the
// closest-note lookup.
if (
hitBlockId &&
this.widget.isBlockDragHandleVisible &&
hitBlockId === this._lastPointerHitBlockId &&
hitBlock === this._lastPointerHitBlockElement &&
isBlockIdEqual(this.widget.anchorBlockId.peek(), hitBlockId)
) {
return;
}
this._lastPointerHitBlockId = hitBlockId;
this._lastPointerHitBlockElement = hitBlock;
// 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.props.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;
const addBlockWidgetContainer = this.widget.addBlockWidgetContainer;
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';
// Position the add-block widget beside the drag handle, aligned to the first line.
if (
addBlockWidgetContainer &&
this.widget.showAddBlockWidget &&
this.widget.mode === 'page'
) {
const posTop = this._getTopWithBlockComponent(block);
addBlockWidgetContainer.style.left = `${draggingAreaRect.left - ADD_BLOCK_WIDGET_WIDTH}px`;
addBlockWidgetContainer.style.top = `${posTop}px`;
addBlockWidgetContainer.style.height = 'auto';
addBlockWidgetContainer.style.display = 'flex';
} else if (addBlockWidgetContainer) {
addBlockWidgetContainer.style.display = 'none';
}
};
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;
this._lastPointerHitBlockId = null;
this._lastPointerHitBlockElement = 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);
}
}

View File

@@ -0,0 +1,25 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/callout" },
{ "path": "../../blocks/embed" },
{ "path": "../../blocks/list" },
{ "path": "../../blocks/note" },
{ "path": "../../blocks/paragraph" },
{ "path": "../../blocks/surface" },
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}

View File

@@ -0,0 +1,34 @@
{
"name": "@blocksuite/affine-widget-edgeless-auto-connect",
"description": "Affine edgeless auto connect widget.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"lit": "^3.2.0"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,11 @@
import {
AFFINE_EDGELESS_AUTO_CONNECT_WIDGET,
EdgelessAutoConnectWidget,
} from '.';
export function effects() {
customElements.define(
AFFINE_EDGELESS_AUTO_CONNECT_WIDGET,
EdgelessAutoConnectWidget
);
}

View File

@@ -0,0 +1,636 @@
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 { Bound } from '@blocksuite/global/gfx';
import {
ArrowLeftSmallIcon,
ArrowRightSmallIcon,
InvisibleIcon,
} from '@blocksuite/icons/lit';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
import {
type GfxController,
GfxControllerIdentifier,
} from '@blocksuite/std/gfx';
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 doc = this.std.store;
const root = doc.root;
if (!root) return;
const pageVisibleBlocks = new Map<AutoConnectElement, number>();
const notes = root.children.filter(child =>
matchModels(child, [NoteBlockModel])
);
const edgelessOnlyNotesSet = new Set<NoteBlockModel>();
notes.forEach(note => {
if (isNoteBlock(note)) {
if (note.props.displayMode$.value === NoteDisplayMode.EdgelessOnly) {
edgelessOnlyNotesSet.add(note);
} else if (
note.props.displayMode$.value === NoteDisplayMode.DocAndEdgeless
) {
pageVisibleBlocks.set(note, 1);
}
}
note.children.forEach(model => {
if (matchModels(model, [SurfaceRefBlockModel])) {
const reference = this._crud.getElementById(model.props.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() {
if (!this.block) {
return;
}
const surfaceRefs = this.block.std.store
.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.props.reference === selectedElements[0].id
))
) {
this._show = true;
} else {
this._show = false;
}
return this._show;
};
this._disposables.add(
this._selection.slots.updated.subscribe(() => {
getVisibility();
})
);
this._disposables.add(
this.store.slots.blockUpdated.subscribe(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.subscribe(payload => {
if (
payload.props['xywh'] &&
surfaceRefs.some(ref => ref.props.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.subscribe(() => {
this.requestUpdate();
})
);
_disposables.add(
gfx.selection.slots.updated.subscribe(() => {
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.subscribe(() => {
this._dragging = true;
})
);
_disposables.add(
slots.elementResizeEnd.subscribe(() => {
this._dragging = false;
})
);
}
override render() {
const advancedVisibilityEnabled = this.store
.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;
}
}

View File

@@ -0,0 +1,23 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { autoConnectWidget } from '.';
import { effects } from './effects';
export class EdgelessAutoConnectViewExtension extends ViewExtensionProvider {
override name = 'affine-edgeless-auto-connect-widget';
override effect() {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
if (this.isEdgeless(context.scope)) {
context.register(autoConnectWidget);
}
}
}

View File

@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/surface" },
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" }
]
}

View File

@@ -0,0 +1,31 @@
{
"name": "@blocksuite/affine-widget-edgeless-dragging-area",
"description": "Affine edgeless dragging area widget.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/std": "workspace:*",
"@toeverything/theme": "^1.1.23",
"lit": "^3.2.0"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,68 @@
import {
DefaultModeDragType,
DefaultTool,
} from '@blocksuite/affine-block-surface';
import type { RootBlockModel } from '@blocksuite/affine-model';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, nothing, unsafeCSS } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { literal, unsafeStatic } from 'lit/static-html.js';
export const EDGELESS_DRAGGING_AREA_WIDGET = 'edgeless-dragging-area-rect';
export class EdgelessDraggingAreaRectWidget extends WidgetComponent<RootBlockModel> {
static override styles = css`
.affine-edgeless-dragging-area {
position: absolute;
background: ${unsafeCSS(
cssVarV2('edgeless/selection/selectionMarqueeBackground', '#1E96EB14')
)};
box-sizing: border-box;
border-width: 1px;
border-style: solid;
border-color: ${unsafeCSS(
cssVarV2('edgeless/selection/selectionMarqueeBorder', '#1E96EB')
)};
z-index: 1;
pointer-events: none;
}
`;
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
override render() {
const rect = this.gfx.tool.draggingViewportArea$.value;
const tool = this.gfx.tool.currentTool$.value;
if (
rect.w === 0 ||
rect.h === 0 ||
!tool ||
!(tool instanceof DefaultTool) ||
tool.dragType !== DefaultModeDragType.Selecting
)
return nothing;
const style = {
left: rect.x + 'px',
top: rect.y + 'px',
width: rect.w + 'px',
height: rect.h + 'px',
};
return html`
<div class="affine-edgeless-dragging-area" style=${styleMap(style)}></div>
`;
}
}
export const edgelessDraggingAreaWidget = WidgetViewExtension(
'affine:page',
EDGELESS_DRAGGING_AREA_WIDGET,
literal`${unsafeStatic(EDGELESS_DRAGGING_AREA_WIDGET)}`
);

View File

@@ -0,0 +1,11 @@
import {
EDGELESS_DRAGGING_AREA_WIDGET,
EdgelessDraggingAreaRectWidget,
} from './edgeless-dragging-area-rect';
export function effects() {
customElements.define(
EDGELESS_DRAGGING_AREA_WIDGET,
EdgelessDraggingAreaRectWidget
);
}

View File

@@ -0,0 +1 @@
export * from './edgeless-dragging-area-rect';

View File

@@ -0,0 +1,23 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { edgelessDraggingAreaWidget } from './edgeless-dragging-area-rect';
import { effects } from './effects';
export class EdgelessDraggingAreaViewExtension extends ViewExtensionProvider {
override name = 'affine-edgeless-dragging-area-widget';
override effect() {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
if (this.isEdgeless(context.scope)) {
context.register(edgelessDraggingAreaWidget);
}
}
}

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/surface" },
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../../framework/std" }
]
}

View File

@@ -0,0 +1,41 @@
{
"name": "@blocksuite/affine-widget-edgeless-selected-rect",
"description": "Affine edgeless selected-rect widget.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-frame": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-gfx-connector": "workspace:*",
"@blocksuite/affine-gfx-shape": "workspace:*",
"@blocksuite/affine-gfx-text": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@lit/context": "^1.1.2",
"@toeverything/theme": "^1.1.23",
"lit": "^3.2.0",
"yjs": "^13.6.27"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,680 @@
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
import {
CanvasElementType,
EdgelessCRUDIdentifier,
getSurfaceBlock,
getSurfaceComponent,
} from '@blocksuite/affine-block-surface';
import { FontFamilyIcon } from '@blocksuite/affine-components/icons';
import {
mountShapeTextEditor,
SHAPE_OVERLAY_HEIGHT,
SHAPE_OVERLAY_WIDTH,
ShapeComponentConfig,
} from '@blocksuite/affine-gfx-shape';
import {
insertEdgelessTextCommand,
mountTextElementEditor,
} from '@blocksuite/affine-gfx-text';
import type {
Connection,
ConnectorElementModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import {
DEFAULT_NOTE_WIDTH,
DefaultTheme,
FontFamily,
FontStyle,
FontWeight,
getShapeName,
GroupElementModel,
NoteBlockModel,
ShapeStyle,
TextElementModel,
} from '@blocksuite/affine-model';
import {
EditPropsStore,
FeatureFlagService,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
captureEventTarget,
matchModels,
} from '@blocksuite/affine-shared/utils';
import type { XYWH } from '@blocksuite/global/gfx';
import {
Bound,
clamp,
normalizeDegAngle,
serializeXYWH,
toDegree,
Vec,
} from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import { FrameIcon, PageIcon } from '@blocksuite/icons/lit';
import {
type BlockComponent,
type BlockStdScope,
stdContext,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { consume } from '@lit/context';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import * as Y from 'yjs';
import {
type AUTO_COMPLETE_TARGET_TYPE,
AutoCompleteFrameOverlay,
AutoCompleteNoteOverlay,
AutoCompleteShapeOverlay,
AutoCompleteTextOverlay,
capitalizeFirstLetter,
createShapeElement,
DEFAULT_NOTE_OVERLAY_HEIGHT,
DEFAULT_TEXT_HEIGHT,
DEFAULT_TEXT_WIDTH,
Direction,
isShape,
PANEL_HEIGHT,
PANEL_WIDTH,
type TARGET_SHAPE_TYPE,
} from './utils.js';
export class EdgelessAutoCompletePanel extends WithDisposable(LitElement) {
static override styles = css`
.auto-complete-panel-container {
position: absolute;
display: flex;
width: 136px;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: 8px 0;
gap: 8px;
border-radius: 8px;
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
z-index: 1;
}
.row-button {
display: flex;
align-items: center;
justify-content: center;
width: 120px;
height: 28px;
padding: 4px 0;
text-align: center;
border-radius: 8px;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
font-size: 12px;
font-style: normal;
font-weight: 500;
border: 1px solid var(--affine-border-color, #e3e2e4);
box-sizing: border-box;
}
`;
private _overlay:
| AutoCompleteShapeOverlay
| AutoCompleteNoteOverlay
| AutoCompleteFrameOverlay
| AutoCompleteTextOverlay
| null = null;
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
constructor(
position: [number, number],
edgeless: BlockComponent,
currentSource: ShapeElementModel | NoteBlockModel,
connector: ConnectorElementModel
) {
super();
this.position = position;
this.edgeless = edgeless;
this.currentSource = currentSource;
this.connector = connector;
}
get crud() {
return this.std.get(EdgelessCRUDIdentifier);
}
get surface() {
return getSurfaceComponent(this.std);
}
private _addFrame() {
const bound = this._generateTarget(this.connector)?.nextBound;
if (!bound) return;
const { h } = bound;
const w = h / 0.75;
const target = this._getTargetXYWH(w, h);
if (!target) return;
const { xywh, position } = target;
const edgeless = this.edgeless;
const surfaceBlockModel = getSurfaceBlock(this.std.store);
if (!surfaceBlockModel) return;
const frameMgr = this.std.get(EdgelessFrameManagerIdentifier);
const frameIndex = frameMgr.frames.length + 1;
const props = this.std.get(EditPropsStore).applyLastProps('affine:frame', {
title: new Y.Text(`Frame ${frameIndex}`),
xywh: serializeXYWH(...xywh),
presentationIndex: frameMgr.generatePresentationIndex(),
});
const id = this.crud.addBlock('affine:frame', props, surfaceBlockModel);
edgeless.store.captureSync();
const frame = this.crud.getElementById(id);
if (!frame) return;
this.connector.target = {
id,
position,
};
this.gfx.selection.set({
elements: [frame.id],
editing: false,
});
}
private _addNote() {
const { store } = this.edgeless;
const target = this._getTargetXYWH(
DEFAULT_NOTE_WIDTH,
DEFAULT_NOTE_OVERLAY_HEIGHT
);
if (!target) return;
const { xywh, position } = target;
const id = this.crud.addBlock(
'affine:note',
{
xywh: serializeXYWH(...xywh),
},
store.root?.id
);
const note = store.getBlock(id)?.model;
if (!matchModels(note, [NoteBlockModel])) {
return;
}
store.addBlock('affine:paragraph', { type: 'text' }, id);
const group = this.currentSource.group;
if (group instanceof GroupElementModel) {
group.addChild(note);
}
this.connector.target = {
id,
position: position as [number, number],
};
this.crud.updateElement(this.connector.id, {
target: { id, position },
});
this.gfx.selection.set({
elements: [id],
editing: false,
});
}
private _addShape(targetType: TARGET_SHAPE_TYPE) {
const edgeless = this.edgeless;
const result = this._generateTarget(this.connector);
if (!result) return;
const currentSource = this.currentSource;
const { nextBound, position } = result;
const id = createShapeElement(edgeless, currentSource, targetType);
if (!id) return;
this.crud.updateElement(id, { xywh: nextBound.serialize() });
this.crud.updateElement(this.connector.id, {
target: { id, position },
});
mountShapeTextEditor(
this.crud.getElementById(id) as ShapeElementModel,
this.edgeless
);
this.gfx.selection.set({
elements: [id],
editing: true,
});
edgeless.store.captureSync();
}
private _addText() {
const target = this._getTargetXYWH(DEFAULT_TEXT_WIDTH, DEFAULT_TEXT_HEIGHT);
if (!target) return;
const { xywh, position } = target;
const bound = Bound.fromXYWH(xywh);
const textFlag = this.edgeless.store
.get(FeatureFlagService)
.getFlag('enable_edgeless_text');
if (textFlag) {
const [_, { textId }] = this.edgeless.std.command.exec(
insertEdgelessTextCommand,
{
x: bound.x,
y: bound.y,
}
);
if (!textId) return;
const textElement = this.crud.getElementById(textId);
if (!textElement) return;
this.crud.updateElement(this.connector.id, {
target: { id: textId, position },
});
if (this.currentSource.group instanceof GroupElementModel) {
this.currentSource.group.addChild(textElement);
}
this.gfx.selection.set({
elements: [textId],
editing: false,
});
this.edgeless.store.captureSync();
} else {
const textId = this.crud.addElement(CanvasElementType.TEXT, {
xywh: bound.serialize(),
text: new Y.Text(),
textAlign: 'left',
fontSize: 24,
fontFamily: FontFamily.Inter,
color: DefaultTheme.textColor,
fontWeight: FontWeight.Regular,
fontStyle: FontStyle.Normal,
});
if (!textId) return;
const textElement = this.crud.getElementById(textId);
if (!(textElement instanceof TextElementModel)) {
return;
}
this.crud.updateElement(this.connector.id, {
target: { id: textId, position },
});
if (this.currentSource.group instanceof GroupElementModel) {
this.currentSource.group.addChild(textElement);
}
this.gfx.selection.set({
elements: [textId],
editing: false,
});
this.edgeless.store.captureSync();
mountTextElementEditor(textElement, this.edgeless);
}
}
private _autoComplete(targetType: AUTO_COMPLETE_TARGET_TYPE) {
this._removeOverlay();
if (!this._connectorExist()) return;
switch (targetType) {
case 'text':
this._addText();
break;
case 'note':
this._addNote();
break;
case 'frame':
this._addFrame();
break;
default:
this._addShape(targetType);
}
this.remove();
}
private _connectorExist() {
return !!this.crud.getElementById(this.connector.id);
}
private _generateTarget(connector: ConnectorElementModel) {
const { currentSource } = this;
let w = SHAPE_OVERLAY_WIDTH;
let h = SHAPE_OVERLAY_HEIGHT;
if (isShape(currentSource)) {
const bound = Bound.deserialize(currentSource.xywh);
w = bound.w;
h = bound.h;
}
const point = connector.target.position;
if (!point) return;
const len = connector.path.length;
const angle = normalizeDegAngle(
toDegree(Vec.angle(connector.path[len - 2], connector.path[len - 1]))
);
let nextBound: Bound;
let position: Connection['position'];
// direction of the connector target arrow
let direction: Direction;
if (angle >= 45 && angle <= 135) {
nextBound = new Bound(point[0] - w / 2, point[1], w, h);
position = [0.5, 0];
direction = Direction.Bottom;
} else if (angle >= 135 && angle <= 225) {
nextBound = new Bound(point[0] - w, point[1] - h / 2, w, h);
position = [1, 0.5];
direction = Direction.Left;
} else if (angle >= 225 && angle <= 315) {
nextBound = new Bound(point[0] - w / 2, point[1] - h, w, h);
position = [0.5, 1];
direction = Direction.Top;
} else {
nextBound = new Bound(point[0], point[1] - h / 2, w, h);
position = [0, 0.5];
direction = Direction.Right;
}
return { nextBound, position, direction };
}
private _getCurrentSourceInfo(): {
style: ShapeStyle;
type: AUTO_COMPLETE_TARGET_TYPE;
} {
const { currentSource } = this;
if (isShape(currentSource)) {
const { shapeType, shapeStyle, radius } = currentSource;
return {
style: shapeStyle,
type: getShapeName(shapeType, radius),
};
}
return {
style: ShapeStyle.General,
type: 'note',
};
}
private _getPanelPosition() {
const { viewport } = this.gfx;
const { boundingClientRect: viewportRect, zoom } = viewport;
const result = this._getTargetXYWH(PANEL_WIDTH / zoom, PANEL_HEIGHT / zoom);
const pos = result ? result.xywh.slice(0, 2) : this.position;
const coord = viewport.toViewCoord(pos[0], pos[1]);
const { width, height } = viewportRect;
coord[0] = clamp(coord[0], 20, width - 20 - PANEL_WIDTH);
coord[1] = clamp(coord[1], 20, height - 20 - PANEL_HEIGHT);
return coord;
}
private _getTargetXYWH(width: number, height: number) {
const result = this._generateTarget(this.connector);
if (!result) return null;
const { nextBound: bound, direction, position } = result;
if (!bound) return null;
const { w, h } = bound;
let x = bound.x;
let y = bound.y;
switch (direction) {
case Direction.Right:
y += h / 2 - height / 2;
break;
case Direction.Bottom:
x -= width / 2 - w / 2;
break;
case Direction.Left:
y += h / 2 - height / 2;
x -= width - w;
break;
case Direction.Top:
x -= width / 2 - w / 2;
y += h - height;
break;
}
const xywh = [x, y, width, height] as XYWH;
return { xywh, position };
}
private _removeOverlay() {
if (this._overlay && this.surface) {
this.surface.renderer.removeOverlay(this._overlay);
}
}
private _showFrameOverlay() {
if (!this.surface) return;
const bound = this._generateTarget(this.connector)?.nextBound;
if (!bound) return;
const { h } = bound;
const w = h / 0.75;
const xywh = this._getTargetXYWH(w, h)?.xywh;
if (!xywh) return;
const strokeColor = this.std
.get(ThemeProvider)
.getCssVariableColor('--affine-black-30');
this._overlay = new AutoCompleteFrameOverlay(this.gfx, xywh, strokeColor);
this.surface.renderer.addOverlay(this._overlay);
}
private _showNoteOverlay() {
const xywh = this._getTargetXYWH(
DEFAULT_NOTE_WIDTH,
DEFAULT_NOTE_OVERLAY_HEIGHT
)?.xywh;
if (!xywh) return;
if (!this.surface) return;
const background = this.edgeless.std
.get(ThemeProvider)
.getColorValue(
this.edgeless.std.get(EditPropsStore).lastProps$.value['affine:note']
.background,
DefaultTheme.noteBackgrounColor,
true
);
this._overlay = new AutoCompleteNoteOverlay(this.gfx, xywh, background);
this.surface.renderer.addOverlay(this._overlay);
}
private _showOverlay(targetType: AUTO_COMPLETE_TARGET_TYPE) {
this._removeOverlay();
if (!this._connectorExist()) return;
if (!this.surface) return;
switch (targetType) {
case 'text':
this._showTextOverlay();
break;
case 'note':
this._showNoteOverlay();
break;
case 'frame':
this._showFrameOverlay();
break;
default:
this._showShapeOverlay(targetType);
}
this.surface.refresh();
}
private _showShapeOverlay(targetType: TARGET_SHAPE_TYPE) {
const bound = this._generateTarget(this.connector)?.nextBound;
if (!bound) return;
if (!this.surface) return;
const { x, y, w, h } = bound;
const xywh = [x, y, w, h] as XYWH;
const { shapeStyle, strokeColor, fillColor, strokeWidth, roughness } =
this.edgeless.std.get(EditPropsStore).lastProps$.value[
`shape:${targetType}`
];
const stroke = this.edgeless.std
.get(ThemeProvider)
.getColorValue(strokeColor, DefaultTheme.shapeStrokeColor, true);
const fill = this.edgeless.std
.get(ThemeProvider)
.getColorValue(fillColor, DefaultTheme.shapeFillColor, true);
const options = {
seed: 666,
roughness: roughness,
strokeLineDash: [0, 0],
stroke,
strokeWidth,
fill,
};
this._overlay = new AutoCompleteShapeOverlay(
this.gfx,
xywh,
targetType,
options,
shapeStyle
);
this.surface.renderer.addOverlay(this._overlay);
}
private _showTextOverlay() {
const xywh = this._getTargetXYWH(
DEFAULT_TEXT_WIDTH,
DEFAULT_TEXT_HEIGHT
)?.xywh;
if (!xywh) return;
if (!this.surface) return;
this._overlay = new AutoCompleteTextOverlay(this.gfx, xywh);
this.surface.renderer.addOverlay(this._overlay);
}
override connectedCallback() {
super.connectedCallback();
this.edgeless.handleEvent('click', ctx => {
const { target } = ctx.get('pointerState').raw;
const element = captureEventTarget(target);
const clickAway = !element?.closest('edgeless-auto-complete-panel');
if (clickAway) this.remove();
});
}
override disconnectedCallback() {
super.disconnectedCallback();
this._removeOverlay();
}
override firstUpdated() {
this.disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(() => this.requestUpdate())
);
}
override render() {
const position = this._getPanelPosition();
if (!position) return nothing;
const style = styleMap({
left: `${position[0]}px`,
top: `${position[1]}px`,
});
const { style: currentSourceStyle, type: currentSourceType } =
this._getCurrentSourceInfo();
const shapeButtons = repeat(
ShapeComponentConfig,
({ name, generalIcon, scribbledIcon, tooltip }) => html`
<edgeless-tool-icon-button
.tooltip=${tooltip}
.iconSize=${'20px'}
@pointerenter=${() => this._showOverlay(name)}
@pointerleave=${() => this._removeOverlay()}
@click=${() => this._autoComplete(name)}
>
${currentSourceStyle === 'General' ? generalIcon : scribbledIcon}
</edgeless-tool-icon-button>
`
);
return html`<div class="auto-complete-panel-container" style=${style}>
${shapeButtons}
<edgeless-tool-icon-button
.tooltip=${'Text'}
.iconSize=${'20px'}
@pointerenter=${() => this._showOverlay('text')}
@pointerleave=${() => this._removeOverlay()}
@click=${() => this._autoComplete('text')}
>
${FontFamilyIcon}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.tooltip=${'Note'}
.iconSize=${'20px'}
@pointerenter=${() => this._showOverlay('note')}
@pointerleave=${() => this._removeOverlay()}
@click=${() => this._autoComplete('note')}
>
${PageIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.tooltip=${'Frame'}
.iconSize=${'20px'}
@pointerenter=${() => this._showOverlay('frame')}
@pointerleave=${() => this._removeOverlay()}
@click=${() => this._autoComplete('frame')}
>
${FrameIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.iconContainerPadding=${0}
.tooltip=${capitalizeFirstLetter(currentSourceType)}
@pointerenter=${() => this._showOverlay(currentSourceType)}
@pointerleave=${() => this._removeOverlay()}
@click=${() => this._autoComplete(currentSourceType)}
>
<div class="row-button">Add a same object</div>
</edgeless-tool-icon-button>
</div>`;
}
@property({ attribute: false })
accessor connector: ConnectorElementModel;
@property({ attribute: false })
accessor currentSource: ShapeElementModel | NoteBlockModel;
@property({ attribute: false })
accessor edgeless: BlockComponent;
@property({ attribute: false })
accessor position: [number, number];
@consume({
context: stdContext,
})
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,787 @@
import {
CanvasElementType,
EdgelessCRUDIdentifier,
getSurfaceBlock,
getSurfaceComponent,
isNoteBlock,
Overlay,
OverlayIdentifier,
type RoughCanvas,
} from '@blocksuite/affine-block-surface';
import {
type ConnectionOverlay,
ConnectorPathGenerator,
} from '@blocksuite/affine-gfx-connector';
import { mountShapeTextEditor } from '@blocksuite/affine-gfx-shape';
import type {
Connection,
ConnectorElementModel,
NoteBlockModel,
ShapeType,
} from '@blocksuite/affine-model';
import {
DEFAULT_NOTE_HEIGHT,
DefaultTheme,
LayoutType,
MindmapElementModel,
ShapeElementModel,
shapeMethods,
} from '@blocksuite/affine-model';
import { ToolbarRegistryIdentifier } from '@blocksuite/affine-shared/services';
import type { SelectedRect } from '@blocksuite/affine-shared/types';
import { handleNativeRangeAtPoint } from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import type { Bound, IVec } from '@blocksuite/global/gfx';
import { Vec } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import {
ArrowUpBigIcon,
PlusIcon,
SiblingNodeIcon,
SubNodeIcon,
} from '@blocksuite/icons/lit';
import {
type BlockComponent,
type BlockStdScope,
stdContext,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { consume } from '@lit/context';
import { css, html, LitElement, nothing } from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { EdgelessAutoCompletePanel } from './auto-complete-panel.js';
import {
createEdgelessElement,
Direction,
getPosition,
isShape,
MAIN_GAP,
nextBound,
} from './utils.js';
class AutoCompleteOverlay extends Overlay {
linePoints: IVec[] = [];
renderShape: ((ctx: CanvasRenderingContext2D) => void) | null = null;
stroke = '';
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
if (this.linePoints.length && this.renderShape) {
ctx.setLineDash([2, 2]);
ctx.strokeStyle = this.stroke;
ctx.beginPath();
this.linePoints.forEach((p, index) => {
if (index === 0) ctx.moveTo(p[0], p[1]);
else ctx.lineTo(p[0], p[1]);
});
ctx.stroke();
this.renderShape(ctx);
ctx.stroke();
}
}
}
export class EdgelessAutoComplete extends WithDisposable(LitElement) {
static override styles = css`
.edgeless-auto-complete-container {
position: absolute;
z-index: 1;
pointer-events: none;
}
.edgeless-auto-complete-arrow-wrapper {
width: 72px;
height: 44px;
position: absolute;
z-index: 1;
pointer-events: auto;
display: flex;
align-items: center;
justify-content: center;
}
.edgeless-auto-complete-arrow-wrapper.hidden {
display: none;
}
.edgeless-auto-complete-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 19px;
cursor: pointer;
pointer-events: auto;
transition:
background 0.3s linear,
box-shadow 0.2s linear;
}
.edgeless-auto-complete-arrow-wrapper.mindmap {
width: 26px;
height: 26px;
}
.edgeless-auto-complete-arrow-wrapper:hover
> .edgeless-auto-complete-arrow {
border: 1px solid var(--affine-border-color);
box-shadow: var(--affine-shadow-1);
background: var(--affine-white);
}
.edgeless-auto-complete-arrow-wrapper
> .edgeless-auto-complete-arrow:hover {
border: 1px solid var(--affine-white-10);
box-shadow: var(--affine-shadow-1);
background: var(--affine-primary-color);
}
.edgeless-auto-complete-arrow-wrapper.mindmap
> .edgeless-auto-complete-arrow {
border: 1px solid var(--affine-border-color);
box-shadow: var(--affine-shadow-1);
background: var(--affine-white);
transition:
background 0.3s linear,
color 0.2s linear;
}
.edgeless-auto-complete-arrow-wrapper.mindmap
> .edgeless-auto-complete-arrow:hover {
border: 1px solid var(--affine-white-10);
box-shadow: var(--affine-shadow-1);
background: var(--affine-primary-color);
}
.edgeless-auto-complete-arrow svg {
fill: #77757d;
color: #77757d;
}
.edgeless-auto-complete-arrow:hover svg {
fill: #ffffff;
color: #ffffff;
}
`;
private get _surface() {
return getSurfaceBlock(this.std.store);
}
private _autoCompleteOverlay!: AutoCompleteOverlay;
private readonly _onPointerDown = (e: PointerEvent, type: Direction) => {
const viewportRect = this.gfx.viewport.boundingClientRect;
const start = this.gfx.viewport.toModelCoord(
e.clientX - viewportRect.left,
e.clientY - viewportRect.top
);
if (!this.edgeless.std.event) return;
let connector: ConnectorElementModel | null;
this._disposables.addFromEvent(document, 'pointermove', e => {
const point = this.gfx.viewport.toModelCoord(
e.clientX - viewportRect.left,
e.clientY - viewportRect.top
);
if (Vec.dist(start, point) > 8 && !this._isMoving) {
if (!this.canShowAutoComplete) return;
this._isMoving = true;
const { startPosition } = getPosition(type);
connector = this._addConnector(
{
id: this.current.id,
position: startPosition,
},
{
position: point,
}
);
}
if (this._isMoving) {
if (!connector) {
return;
}
const otherSideId = connector.source.id;
connector.target = this.connectionOverlay.renderConnector(
point,
otherSideId ? [otherSideId] : []
);
this.std.get(ToolbarRegistryIdentifier).flags.hide();
}
});
this._disposables.addFromEvent(document, 'pointerup', e => {
if (!this._isMoving) {
this._generateElementOnClick(type);
} else if (connector && !connector.target.id) {
this.gfx.selection.clear();
this._createAutoCompletePanel(e, connector);
}
this.std.get(ToolbarRegistryIdentifier).flags.show();
this._isMoving = false;
this.connectionOverlay.clear();
this._disposables.dispose();
this._disposables = new DisposableGroup();
});
};
private _pathGenerator!: ConnectorPathGenerator;
private _timer: ReturnType<typeof setTimeout> | null = null;
get canShowAutoComplete() {
const { current } = this;
return isShape(current) || isNoteBlock(current);
}
get connectionOverlay() {
return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay;
}
get crud() {
return this.std.get(EdgelessCRUDIdentifier);
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
private _addConnector(source: Connection, target: Connection) {
const id = this.crud.addElement(CanvasElementType.CONNECTOR, {
source,
target,
});
if (!id) return null;
return this.crud.getElementById(id) as ConnectorElementModel;
}
private _addMindmapNode(target: 'sibling' | 'child') {
const mindmap = this.current.group;
if (!(mindmap instanceof MindmapElementModel)) return;
const parent =
target === 'sibling'
? (mindmap.getParentNode(this.current.id) ?? this.current)
: this.current;
const parentNode = mindmap.getNode(parent.id);
if (!parentNode) return;
const newNode = mindmap.addNode(
parentNode.id,
target === 'sibling' ? this.current.id : undefined,
undefined,
undefined
);
if (parentNode.detail.collapsed) {
mindmap.toggleCollapse(parentNode);
}
requestAnimationFrame(() => {
mountShapeTextEditor(
this.crud.getElementById(newNode) as ShapeElementModel,
this.edgeless
);
});
}
private _computeLine(
type: Direction,
curShape: ShapeElementModel,
nextBound: Bound
) {
const startBound = this.current.elementBound;
const { startPosition, endPosition } = getPosition(type);
const nextShape = {
xywh: nextBound.serialize(),
rotate: curShape.rotate,
shapeType: curShape.shapeType,
};
const startPoint = curShape.getRelativePointLocation(startPosition);
const endPoint = curShape.getRelativePointLocation.call(
nextShape,
endPosition
);
return this._pathGenerator.generateOrthogonalConnectorPath({
startBound,
endBound: nextBound,
startPoint,
endPoint,
});
}
private _computeNextBound(type: Direction) {
if (isShape(this.current)) {
const connectedShapes = this._getConnectedElements(this.current).filter(
e => e instanceof ShapeElementModel
) as ShapeElementModel[];
return nextBound(type, this.current, connectedShapes);
} else {
const bound = this.current.elementBound;
switch (type) {
case Direction.Right: {
bound.x += bound.w + MAIN_GAP;
break;
}
case Direction.Bottom: {
bound.y += bound.h + MAIN_GAP;
break;
}
case Direction.Left: {
bound.x -= bound.w + MAIN_GAP;
break;
}
case Direction.Top: {
bound.y -= bound.h + MAIN_GAP;
break;
}
}
return bound;
}
}
private _createAutoCompletePanel(
e: PointerEvent,
connector: ConnectorElementModel
) {
if (!this.canShowAutoComplete) return;
const position = this.gfx.viewport.toModelCoord(e.clientX, e.clientY);
const autoCompletePanel = new EdgelessAutoCompletePanel(
position,
this.edgeless,
this.current,
connector
);
this.edgeless.append(autoCompletePanel);
}
private _generateElementOnClick(type: Direction) {
const { store } = this.edgeless;
const bound = this._computeNextBound(type);
const id = createEdgelessElement(this.edgeless, this.current, bound);
if (!id) return;
if (isShape(this.current)) {
const { startPosition, endPosition } = getPosition(type);
this._addConnector(
{
id: this.current.id,
position: startPosition,
},
{
id,
position: endPosition,
}
);
mountShapeTextEditor(
this.crud.getElementById(id) as ShapeElementModel,
this.edgeless
);
} else {
const model = store.getModelById(id);
if (!model) {
return;
}
const [x, y] = this.gfx.viewport.toViewCoord(
bound.center[0],
bound.y + DEFAULT_NOTE_HEIGHT / 2
);
requestAnimationFrame(() => {
handleNativeRangeAtPoint(x, y);
});
}
this.gfx.selection.set({
elements: [id],
editing: true,
});
this.removeOverlay();
}
private _getConnectedElements(element: ShapeElementModel) {
if (!this._surface) return [];
return this._surface.getConnectors(element.id).reduce((prev, current) => {
if (current.target.id === element.id && current.source.id) {
prev.push(
this.crud.getElementById(current.source.id) as ShapeElementModel
);
}
if (current.source.id === element.id && current.target.id) {
prev.push(
this.crud.getElementById(current.target.id) as ShapeElementModel
);
}
return prev;
}, [] as ShapeElementModel[]);
}
private _getMindmapButtons() {
const mindmap = this.current.group as MindmapElementModel;
const mindmapDirection =
this.current instanceof ShapeElementModel &&
mindmap instanceof MindmapElementModel
? mindmap.getLayoutDir(this.current.id)
: null;
const isRoot = mindmap?.tree.id === this.current.id;
const mindmapNode = mindmap.getNode(this.current.id);
let buttons: [
Direction,
'child' | 'sibling',
LayoutType.LEFT | LayoutType.RIGHT,
][] = [];
switch (mindmapDirection) {
case LayoutType.LEFT:
buttons = [[Direction.Left, 'child', LayoutType.LEFT]];
if (!isRoot) {
buttons.push([Direction.Bottom, 'sibling', mindmapDirection]);
}
break;
case LayoutType.RIGHT:
buttons = [[Direction.Right, 'child', LayoutType.RIGHT]];
if (!isRoot) {
buttons.push([Direction.Bottom, 'sibling', mindmapDirection]);
}
break;
case LayoutType.BALANCE:
buttons = [
[Direction.Right, 'child', LayoutType.RIGHT],
[Direction.Left, 'child', LayoutType.LEFT],
];
break;
default:
buttons = [];
}
return buttons.length
? {
mindmapNode,
buttons,
}
: null;
}
private _initOverlay() {
const surface = getSurfaceComponent(this.std);
if (!surface) return;
this._autoCompleteOverlay = new AutoCompleteOverlay(this.gfx);
surface.renderer.addOverlay(this._autoCompleteOverlay);
}
private _renderArrow() {
const isShape = this.current instanceof ShapeElementModel;
const { selectedRect } = this;
const { zoom } = this.gfx.viewport;
const width = 72;
const height = 44;
// Auto-complete arrows for shape and note are different
// Shape: right, bottom, left, top
// Note: right, left
const arrowDirections = isShape
? [Direction.Right, Direction.Bottom, Direction.Left, Direction.Top]
: [Direction.Right, Direction.Left];
const arrowMargin = isShape ? height / 2 : height * (2 / 3);
const Arrows = arrowDirections.map(type => {
let transform = '';
const iconSize = { width: '16px', height: '16px' };
const icon = (isShape ? ArrowUpBigIcon : PlusIcon)(iconSize);
switch (type) {
case Direction.Top:
transform += `translate(${
selectedRect.width / 2
}px, ${-arrowMargin}px)`;
break;
case Direction.Right:
transform += `translate(${selectedRect.width + arrowMargin}px, ${
selectedRect.height / 2
}px)`;
isShape && (transform += `rotate(90deg)`);
break;
case Direction.Bottom:
transform += `translate(${selectedRect.width / 2}px, ${
selectedRect.height + arrowMargin
}px)`;
isShape && (transform += `rotate(180deg)`);
break;
case Direction.Left:
transform += `translate(${-arrowMargin}px, ${
selectedRect.height / 2
}px)`;
isShape && (transform += `rotate(-90deg)`);
break;
}
transform += `translate(${-width / 2}px, ${-height / 2}px)`;
const arrowWrapperClasses = classMap({
'edgeless-auto-complete-arrow-wrapper': true,
hidden: !isShape && type === Direction.Left && zoom >= 1.5,
});
return html`<div
class=${arrowWrapperClasses}
style=${styleMap({
transform,
transformOrigin: 'left top',
})}
>
<div
class="edgeless-auto-complete-arrow"
@mouseenter=${() => {
this._timer = setTimeout(() => {
if (this.current instanceof ShapeElementModel) {
const bound = this._computeNextBound(type);
const path = this._computeLine(type, this.current, bound);
this._showNextShape(
this.current,
bound,
path,
this.current.shapeType
);
}
}, 300);
}}
@mouseleave=${() => {
this.removeOverlay();
}}
@pointerdown=${(e: PointerEvent) => {
this._onPointerDown(e, type);
}}
>
${icon}
</div>
</div>`;
});
return Arrows;
}
private _renderMindMapButtons() {
const mindmapButtons = this._getMindmapButtons();
if (!mindmapButtons) {
return;
}
const { selectedRect } = this;
const { zoom } = this.gfx.viewport;
const size = 26;
const buttonMargin =
(mindmapButtons.mindmapNode?.children.length ?? 0) > 0
? size / 2 + 32 * zoom
: size / 2 + 6;
const verticalMargin = size / 2 + 6;
return mindmapButtons.buttons.map(type => {
let transform = '';
const [position, target, layout] = type;
const isLeftLayout = layout === LayoutType.LEFT;
const icon = (target === 'child' ? SubNodeIcon : SiblingNodeIcon)({
width: '16px',
height: '16px',
});
switch (position) {
case Direction.Bottom:
transform += `translate(${selectedRect.width / 2}px, ${
selectedRect.height + verticalMargin
}px)`;
isLeftLayout && (transform += `scale(-1)`);
break;
case Direction.Right:
transform += `translate(${selectedRect.width + buttonMargin}px, ${
selectedRect.height / 2
}px)`;
break;
case Direction.Left:
transform += `translate(${-buttonMargin}px, ${
selectedRect.height / 2
}px)`;
transform += `scale(-1)`;
break;
}
transform += `translate(${-size / 2}px, ${-size / 2}px)`;
const arrowWrapperClasses = classMap({
'edgeless-auto-complete-arrow-wrapper': true,
hidden: position === Direction.Left && zoom >= 1.5,
mindmap: true,
});
return html`<div
class=${arrowWrapperClasses}
style=${styleMap({
transform,
transformOrigin: 'left top',
})}
>
<div
class="edgeless-auto-complete-arrow"
@pointerdown=${() => {
this._addMindmapNode(target);
}}
>
${icon}
</div>
</div>`;
});
}
private _showNextShape(
current: ShapeElementModel,
bound: Bound,
path: IVec[],
targetType: ShapeType
) {
const surface = getSurfaceComponent(this.std);
if (!surface) return;
this._autoCompleteOverlay.stroke = surface.renderer.getColorValue(
current.strokeColor,
DefaultTheme.shapeStrokeColor,
true
);
this._autoCompleteOverlay.linePoints = path;
this._autoCompleteOverlay.renderShape = ctx => {
shapeMethods[targetType].draw(ctx, { ...bound, rotate: current.rotate });
};
surface.refresh();
}
override connectedCallback(): void {
super.connectedCallback();
this._pathGenerator = new ConnectorPathGenerator({
getElementById: id => this.crud.getElementById(id),
});
this._initOverlay();
}
override firstUpdated() {
const { _disposables, edgeless, gfx } = this;
_disposables.add(
this.gfx.selection.slots.updated.subscribe(() => {
this._autoCompleteOverlay.linePoints = [];
this._autoCompleteOverlay.renderShape = null;
})
);
_disposables.add(
gfx.selection.slots.updated.subscribe(() => {
this.requestUpdate();
})
);
_disposables.add(() => this.removeOverlay());
_disposables.add(
edgeless.host.event.add('pointerMove', ctx => {
const evt = ctx.get('pointerState');
const [x, y] = gfx.viewport.toModelCoord(evt.x, evt.y);
const elm = gfx.getElementByPoint(x, y);
if (!elm) {
this._isHover = false;
return;
}
this._isHover = elm === this.current ? true : false;
})
);
this.edgeless.handleEvent('dragStart', () => {
this._isMoving = true;
});
this.edgeless.handleEvent('dragEnd', () => {
this._isMoving = false;
});
}
private _canAutoComplete() {
const selection = this.gfx.selection;
return (
selection.selectedElements.length === 1 &&
(selection.selectedElements[0] instanceof ShapeElementModel ||
isNoteBlock(selection.selectedElements[0]))
);
}
removeOverlay() {
this._timer && clearTimeout(this._timer);
const surface = getSurfaceComponent(this.std);
if (!surface) return;
surface.renderer.removeOverlay(this._autoCompleteOverlay);
}
override render() {
const isShape = this.current instanceof ShapeElementModel;
const isMindMap = this.current.group instanceof MindmapElementModel;
if (
this._isMoving ||
(this._isHover && !isShape && !this._canAutoComplete())
) {
this.removeOverlay();
return nothing;
}
const { selectedRect } = this;
return html`<div
class="edgeless-auto-complete-container"
style=${styleMap({
top: selectedRect.top + 'px',
left: selectedRect.left + 'px',
width: selectedRect.width + 'px',
height: selectedRect.height + 'px',
transform: `rotate(${selectedRect.rotate}deg)`,
})}
>
${isMindMap ? this._renderMindMapButtons() : this._renderArrow()}
</div>`;
}
@state()
private accessor _isHover = true;
@state()
private accessor _isMoving = false;
@property({ attribute: false })
accessor current!: ShapeElementModel | NoteBlockModel;
@property({ attribute: false })
accessor edgeless!: BlockComponent;
@property({ attribute: false })
accessor selectedRect!: SelectedRect;
@consume({
context: stdContext,
})
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,849 @@
import { type FrameOverlay } from '@blocksuite/affine-block-frame';
import {
EdgelessLegacySlotIdentifier,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import {
ConnectorElementModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { SelectedRect } from '@blocksuite/affine-shared/types';
import {
getSelectedRect,
requestThrottledConnectedFrame,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { deserializeXYWH } from '@blocksuite/global/gfx';
import { WidgetComponent } from '@blocksuite/std';
import {
type CursorType,
GfxControllerIdentifier,
type GfxModel,
InteractivityIdentifier,
type ResizeHandle,
} from '@blocksuite/std/gfx';
import { css, html, nothing } from 'lit';
import { state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { RenderResizeHandles } from './resize-handles.js';
import { generateCursorUrl, getRotatedResizeCursor } from './utils.js';
export const EDGELESS_SELECTED_RECT_WIDGET = 'edgeless-selected-rect';
export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel> {
// disable change-in-update warning
static override enabledWarnings = [];
static override styles = css`
:host {
display: block;
user-select: none;
contain: size layout;
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
.affine-edgeless-selected-rect {
position: absolute;
top: 0;
left: 0;
transform-origin: center center;
border-radius: 0;
pointer-events: none;
box-sizing: border-box;
z-index: 1;
border-color: var(--affine-blue);
border-width: 2px;
border-style: solid;
transform: translate(0, 0) rotate(0);
}
.affine-edgeless-selected-rect[data-locked='true'] {
border-color: ${unsafeCSSVarV2('edgeless/lock/locked', '#00000085')};
}
.affine-edgeless-selected-rect .handle {
position: absolute;
user-select: none;
outline: none;
pointer-events: auto;
/**
* Fix: pointerEvent stops firing after a short time.
* When a gesture is started, the browser intersects the touch-action values of the touched element and its ancestors,
* up to the one that implements the gesture (in other words, the first containing scrolling element)
* https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action
*/
touch-action: none;
}
.affine-edgeless-selected-rect .handle[aria-label^='top-'],
.affine-edgeless-selected-rect .handle[aria-label^='bottom-'] {
width: 18px;
height: 18px;
box-sizing: border-box;
z-index: 10;
}
.affine-edgeless-selected-rect .handle[aria-label^='top-'] .resize,
.affine-edgeless-selected-rect .handle[aria-label^='bottom-'] .resize {
position: absolute;
width: 12px;
height: 12px;
box-sizing: border-box;
border-radius: 50%;
border: 2px var(--affine-blue) solid;
background: white;
}
.affine-edgeless-selected-rect .handle[aria-label^='top-'] .rotate,
.affine-edgeless-selected-rect .handle[aria-label^='bottom-'] .rotate {
position: absolute;
width: 12px;
height: 12px;
box-sizing: border-box;
background: transparent;
}
/* -18 + 6.5 */
.affine-edgeless-selected-rect .handle[aria-label='top-left'] {
left: -12px;
top: -12px;
}
.affine-edgeless-selected-rect .handle[aria-label='top-left'] .resize {
right: 0;
bottom: 0;
}
.affine-edgeless-selected-rect .handle[aria-label='top-left'] .rotate {
right: 6px;
bottom: 6px;
}
.affine-edgeless-selected-rect .handle[aria-label='top-right'] {
top: -12px;
right: -12px;
}
.affine-edgeless-selected-rect .handle[aria-label='top-right'] .resize {
left: 0;
bottom: 0;
}
.affine-edgeless-selected-rect .handle[aria-label='top-right'] .rotate {
left: 6px;
bottom: 6px;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom-right'] {
right: -12px;
bottom: -12px;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom-right'] .resize {
left: 0;
top: 0;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom-right'] .rotate {
left: 6px;
top: 6px;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom-left'] {
bottom: -12px;
left: -12px;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom-left'] .resize {
right: 0;
top: 0;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom-left'] .rotate {
right: 6px;
top: 6px;
}
.affine-edgeless-selected-rect .handle[aria-label='top'],
.affine-edgeless-selected-rect .handle[aria-label='bottom'],
.affine-edgeless-selected-rect .handle[aria-label='left'],
.affine-edgeless-selected-rect .handle[aria-label='right'] {
border: 0;
background: transparent;
border-color: var('--affine-blue');
}
.affine-edgeless-selected-rect .handle[aria-label='left'],
.affine-edgeless-selected-rect .handle[aria-label='right'] {
top: 0;
bottom: 0;
height: 100%;
width: 6px;
}
.affine-edgeless-selected-rect .handle[aria-label='top'],
.affine-edgeless-selected-rect .handle[aria-label='bottom'] {
left: 0;
right: 0;
width: 100%;
height: 6px;
}
/* calc(-1px - (6px - 1px) / 2) = -3.5px */
.affine-edgeless-selected-rect .handle[aria-label='left'] {
left: -3.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='right'] {
right: -3.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='top'] {
top: -3.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom'] {
bottom: -3.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='top'] .resize,
.affine-edgeless-selected-rect .handle[aria-label='bottom'] .resize,
.affine-edgeless-selected-rect .handle[aria-label='left'] .resize,
.affine-edgeless-selected-rect .handle[aria-label='right'] .resize {
width: 100%;
height: 100%;
}
.affine-edgeless-selected-rect .handle[aria-label='top'] .resize:after,
.affine-edgeless-selected-rect .handle[aria-label='bottom'] .resize:after,
.affine-edgeless-selected-rect .handle[aria-label='left'] .resize:after,
.affine-edgeless-selected-rect .handle[aria-label='right'] .resize:after {
position: absolute;
width: 7px;
height: 7px;
box-sizing: border-box;
border-radius: 6px;
z-index: 10;
content: '';
background: white;
}
.affine-edgeless-selected-rect
.handle[aria-label='top']
.transparent-handle:after,
.affine-edgeless-selected-rect
.handle[aria-label='bottom']
.transparent-handle:after,
.affine-edgeless-selected-rect
.handle[aria-label='left']
.transparent-handle:after,
.affine-edgeless-selected-rect
.handle[aria-label='right']
.transparent-handle:after {
opacity: 0;
}
.affine-edgeless-selected-rect .handle[aria-label='left'] .resize:after,
.affine-edgeless-selected-rect .handle[aria-label='right'] .resize:after {
top: calc(50% - 6px);
}
.affine-edgeless-selected-rect .handle[aria-label='top'] .resize:after,
.affine-edgeless-selected-rect .handle[aria-label='bottom'] .resize:after {
left: calc(50% - 6px);
}
.affine-edgeless-selected-rect .handle[aria-label='left'] .resize:after {
left: -0.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='right'] .resize:after {
right: -0.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='top'] .resize:after {
top: -0.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom'] .resize:after {
bottom: -0.5px;
}
.affine-edgeless-selected-rect .handle .resize::before {
content: '';
display: none;
position: absolute;
width: 20px;
height: 20px;
background-image: url("data:image/svg+xml,%3Csvg width='26' height='26' viewBox='0 0 26 26' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M23 3H19C10.1634 3 3 10.1634 3 19V23' stroke='black' stroke-opacity='0.3' stroke-width='5' stroke-linecap='round'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
}
.affine-edgeless-selected-rect[data-mode='scale']
.handle[aria-label='top-left']
.resize:hover::before,
.affine-edgeless-selected-rect[data-scale-direction='top-left'][data-scale-percent]
.handle[aria-label='top-left']
.resize::before {
display: block;
top: 0px;
left: 0px;
transform: translate(-100%, -100%);
}
.affine-edgeless-selected-rect[data-mode='scale']
.handle[aria-label='top-right']
.resize:hover::before,
.affine-edgeless-selected-rect[data-scale-direction='top-right'][data-scale-percent]
.handle[aria-label='top-right']
.resize::before {
display: block;
top: 0px;
right: 0px;
transform: translate(100%, -100%) rotate(90deg);
}
.affine-edgeless-selected-rect[data-mode='scale']
.handle[aria-label='bottom-right']
.resize:hover::before,
.affine-edgeless-selected-rect[data-scale-direction='bottom-right'][data-scale-percent]
.handle[aria-label='bottom-right']
.resize::before {
display: block;
bottom: 0px;
right: 0px;
transform: translate(100%, 100%) rotate(180deg);
}
.affine-edgeless-selected-rect[data-mode='scale']
.handle[aria-label='bottom-left']
.resize:hover::before,
.affine-edgeless-selected-rect[data-scale-direction='bottom-left'][data-scale-percent]
.handle[aria-label='bottom-left']
.resize::before {
display: block;
bottom: 0px;
left: 0px;
transform: translate(-100%, 100%) rotate(-90deg);
}
.affine-edgeless-selected-rect::after {
content: attr(data-scale-percent);
display: none;
position: absolute;
color: var(--affine-icon-color);
font-feature-settings:
'clig' off,
'liga' off;
font-family: var(--affine-font-family);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 24px;
}
.affine-edgeless-selected-rect[data-scale-direction='top-left']::after {
display: block;
top: -20px;
left: -20px;
transform: translate(-100%, -100%);
}
.affine-edgeless-selected-rect[data-scale-direction='top-right']::after {
display: block;
top: -20px;
right: -20px;
transform: translate(100%, -100%);
}
.affine-edgeless-selected-rect[data-scale-direction='bottom-right']::after {
display: block;
bottom: -20px;
right: -20px;
transform: translate(100%, 100%);
}
.affine-edgeless-selected-rect[data-scale-direction='bottom-left']::after {
display: block;
bottom: -20px;
left: -20px;
transform: translate(-100%, 100%);
}
`;
private readonly _dragEndCleanup = () => {
this._isWidthLimit = false;
this._isHeightLimit = false;
this._scalePercent = undefined;
this._scaleDirection = undefined;
this._updateCursor();
this.frameOverlay.clear();
};
private readonly _updateCursor = (options?: {
type: 'resize' | 'rotate';
angle: number;
handle: ResizeHandle;
flipX?: boolean;
flipY?: boolean;
pure?: boolean;
}) => {
if (!options) {
!this._isResizing && (this.gfx.cursor$.value = 'default');
return 'default';
}
const { type, angle, flipX, flipY } = options;
let cursor: CursorType = 'default';
let handle: ResizeHandle = options.handle;
if (flipX) {
handle = (
handle.includes('left')
? handle.replace('left', 'right')
: handle.replace('right', 'left')
) as ResizeHandle;
}
if (flipY) {
handle = (
handle.includes('top')
? handle.replace('top', 'bottom')
: handle.replace('bottom', 'top')
) as ResizeHandle;
}
if (type === 'rotate') {
cursor = generateCursorUrl(angle, handle);
} else {
cursor = getRotatedResizeCursor({
handle,
angle,
});
}
if (options.pure !== true) {
this.gfx.cursor$.value = cursor;
}
return cursor;
};
private readonly _updateOnElementChange = (
element: string | { id: string }
) => {
const id = typeof element === 'string' ? element : element.id;
if (this.selection.has(id)) {
this._updateSelectedRect();
}
};
private readonly _updateHandles = () => {
const interaction = this._interaction;
const { store, selection } = this;
const elements = selection.selectedElements;
if (interaction && !selection.editing && !store.readonly) {
const resizeHandles = interaction.getResizeHandlers({
elements,
});
const { rotatable } = interaction.getRotateConfig({
elements,
});
this._allowedHandles = {
rotatable,
resizeHandles,
};
} else {
this._allowedHandles = null;
}
};
private readonly _updateOnSelectionChange = () => {
this._updateHandles();
this._updateSelectedRect();
// Reset the cursor
this._updateCursor();
};
@state()
private accessor _selectedRect: SelectedRect = {
width: 0,
height: 0,
left: 0,
top: 0,
rotate: 0,
borderWidth: 0,
borderStyle: 'solid',
};
private readonly _updateSelectedRect = requestThrottledConnectedFrame(() => {
const { zoom, selection, gfx } = this;
const elements = selection.selectedElements;
const rect = getSelectedRect(elements);
// Compensate for outer CSS scale (e.g. embed-edgeless-synced-doc),
// matching GfxBlockComponent.getCSSTransform.
const { viewportX, viewportY, viewScale } = gfx.viewport;
const left = ((rect.left - viewportX) * zoom) / viewScale;
const top = ((rect.top - viewportY) * zoom) / viewScale;
const width = (rect.width * zoom) / viewScale;
const height = (rect.height * zoom) / viewScale;
let rotate = 0;
if (elements.length === 1 && elements[0].rotate) {
rotate = elements[0].rotate;
}
this._selectedRect = {
width,
height,
left,
top,
rotate,
borderStyle: 'solid',
borderWidth: 2,
};
}, this);
get frameOverlay() {
return this.std.get(OverlayIdentifier('frame')) as FrameOverlay;
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get selection() {
return this.gfx.selection;
}
get surface() {
return this.gfx.surface;
}
get zoom() {
return this.gfx.viewport.zoom;
}
constructor() {
super();
this.addEventListener('pointerdown', stopPropagation);
}
private _shouldRenderSelection(elements?: GfxModel[]) {
elements = elements ?? this.selection.selectedElements;
return elements.length > 0 && !this.selection.editing;
}
override firstUpdated() {
const { _disposables, selection, gfx } = this;
_disposables.add(
// viewport zooming / scrolling
gfx.viewport.viewportUpdated.subscribe(this._updateSelectedRect)
);
if (gfx.surface) {
_disposables.add(
gfx.surface.elementAdded.subscribe(this._updateOnElementChange)
);
_disposables.add(
gfx.surface.elementRemoved.subscribe(this._updateOnElementChange)
);
_disposables.add(
gfx.surface.elementUpdated.subscribe(this._updateOnElementChange)
);
}
_disposables.add(
this.store.slots.blockUpdated.subscribe(this._updateOnElementChange)
);
_disposables.add(
selection.slots.updated.subscribe(this._updateOnSelectionChange)
);
_disposables.add(
this._slots.readonlyUpdated.subscribe(() => this.requestUpdate())
);
_disposables.add(
this._slots.elementResizeEnd.subscribe(() => (this._isResizing = false))
);
if (this._interaction) {
_disposables.add(
this._interaction.activeInteraction$.subscribe(val => {
const pre = this._isResizing;
const newVal = val?.type === 'resize' || val?.type === 'rotate';
if (pre === newVal) {
return;
}
this._isResizing = newVal;
if (newVal) {
this._slots.elementResizeStart.next();
} else {
this._slots.elementResizeEnd.next();
}
})
);
}
}
private get _interaction() {
return this.std.getOptional(InteractivityIdentifier);
}
private get _slots() {
return this.std.get(EdgelessLegacySlotIdentifier);
}
private _renderHandles() {
const { selection, gfx, block } = this;
const elements = selection.selectedElements;
if (selection.inoperable) {
return [];
}
const handles = [];
if (
this._allowedHandles &&
this._interaction &&
!elements.some(element => element.isLocked())
) {
const interaction = this._interaction;
handles.push(
RenderResizeHandles(
this._allowedHandles.resizeHandles,
this._allowedHandles.rotatable,
(e: PointerEvent, handle: ResizeHandle) => {
const isRotate = (e.target as HTMLElement).classList.contains(
'rotate'
);
if (isRotate) {
interaction.handleElementRotate({
elements,
event: e,
onRotateStart: () => {
this._mode = 'rotate';
},
onRotateUpdate: payload => {
this._updateCursor({
type: 'rotate',
angle: payload.currentAngle,
handle,
});
},
onRotateEnd: () => {
this._mode = 'resize';
this._dragEndCleanup();
},
});
} else {
interaction.handleElementResize({
elements,
handle,
event: e,
onResizeStart: () => {
this._mode = 'resize';
},
onResizeUpdate: ({ lockRatio, scaleX, scaleY, exceed }) => {
if (lockRatio) {
this._scaleDirection = handle;
this._scalePercent = `${Math.round(scaleX * 100)}%`;
this._mode = 'scale';
}
if (exceed) {
this._isWidthLimit = exceed.w;
this._isHeightLimit = exceed.h;
}
this._updateCursor({
type: 'resize',
angle: elements.length > 1 ? 0 : (elements[0]?.rotate ?? 0),
handle,
flipX: scaleX < 0,
flipY: scaleY < 0,
});
},
onResizeEnd: () => {
this._mode = 'resize';
this._dragEndCleanup();
},
});
}
},
option => {
if (
['resize', 'rotate'].includes(
interaction.activeInteraction$.value?.type ?? ''
)
) {
return '';
}
return this._updateCursor({
...option,
angle: elements.length > 1 ? 0 : (elements[0]?.rotate ?? 0),
pure: true,
});
}
)
);
}
if (
elements.length === 1 &&
elements[0] instanceof ConnectorElementModel &&
!elements[0].isLocked()
) {
handles.push(html`
<edgeless-connector-handle
.connector=${elements[0]}
.edgeless=${block}
></edgeless-connector-handle>
`);
}
if (
elements.length > 1 &&
!elements.some(e => e instanceof ConnectorElementModel)
) {
handles.push(
repeat(
elements,
element => element.id,
element => {
const [modelX, modelY, w, h] = deserializeXYWH(element.xywh);
const { viewportX, viewportY, zoom, viewScale } = gfx.viewport;
const x = ((modelX - viewportX) * zoom) / viewScale;
const y = ((modelY - viewportY) * zoom) / viewScale;
const { left, top, borderWidth } = this._selectedRect;
const style = {
position: 'absolute',
boxSizing: 'border-box',
left: `${x - left - borderWidth}px`,
top: `${y - top - borderWidth}px`,
width: `${(w * zoom) / viewScale}px`,
height: `${(h * zoom) / viewScale}px`,
transform: `rotate(${element.rotate}deg)`,
border: `1px solid var(--affine-primary-color)`,
};
return html`<div
class="element-handle"
style=${styleMap(style)}
></div>`;
}
)
);
}
return handles;
}
private _renderAutoComplete() {
const { store, selection, block, _selectedRect } = this;
return !store.readonly &&
!selection.inoperable &&
!this.autoCompleteOff &&
!this._isResizing
? html`<edgeless-auto-complete
.current=${selection.selectedElements[0]}
.edgeless=${block}
.selectedRect=${_selectedRect}
>
</edgeless-auto-complete>`
: nothing;
}
override render() {
const elements = this.selection.selectedElements;
if (!this._shouldRenderSelection(elements)) return nothing;
const { _selectedRect } = this;
const hasElementLocked = elements.some(element => element.isLocked());
const handlers = this._renderHandles();
const isConnector =
elements.length === 1 && elements[0] instanceof ConnectorElementModel;
return html`
<style>
.affine-edgeless-selected-rect .handle[aria-label='right']::after {
content: '';
display: ${this._isWidthLimit ? 'initial' : 'none'};
position: absolute;
top: 0;
left: 1.5px;
width: 2px;
height: 100%;
background: var(--affine-error-color);
filter: drop-shadow(-6px 0px 12px rgba(235, 67, 53, 0.35));
}
.affine-edgeless-selected-rect .handle[aria-label='bottom']::after {
content: '';
display: ${this._isHeightLimit ? 'initial' : 'none'};
position: absolute;
top: 1.5px;
left: 0px;
width: 100%;
height: 2px;
background: var(--affine-error-color);
filter: drop-shadow(-6px 0px 12px rgba(235, 67, 53, 0.35));
}
</style>
${this._renderAutoComplete()}
<div
class="affine-edgeless-selected-rect"
style=${styleMap({
width: `${_selectedRect.width}px`,
height: `${_selectedRect.height}px`,
borderWidth: `${_selectedRect.borderWidth}px`,
borderStyle: isConnector ? 'none' : _selectedRect.borderStyle,
transform: `translate(${_selectedRect.left}px, ${_selectedRect.top}px) rotate(${_selectedRect.rotate}deg)`,
})}
disabled="true"
data-mode=${this._mode}
data-scale-percent=${ifDefined(this._scalePercent)}
data-scale-direction=${ifDefined(this._scaleDirection)}
data-locked=${hasElementLocked}
>
${handlers}
</div>
`;
}
@state()
private accessor _allowedHandles: {
rotatable: boolean;
resizeHandles: ResizeHandle[];
} | null = null;
@state()
private accessor _isHeightLimit = false;
@state()
private accessor _isResizing = false;
@state()
private accessor _isWidthLimit = false;
@state()
private accessor _mode: 'resize' | 'scale' | 'rotate' = 'resize';
@state()
private accessor _scaleDirection: ResizeHandle | undefined = undefined;
@state()
private accessor _scalePercent: string | undefined = undefined;
@state()
accessor autoCompleteOff = false;
}

View File

@@ -0,0 +1,18 @@
import { EdgelessAutoCompletePanel } from './auto-complete-panel';
import { EdgelessAutoComplete } from './edgeless-auto-complete';
import {
EDGELESS_SELECTED_RECT_WIDGET,
EdgelessSelectedRectWidget,
} from './edgeless-selected-rect';
export function effects() {
customElements.define(
'edgeless-auto-complete-panel',
EdgelessAutoCompletePanel
);
customElements.define('edgeless-auto-complete', EdgelessAutoComplete);
customElements.define(
EDGELESS_SELECTED_RECT_WIDGET,
EdgelessSelectedRectWidget
);
}

View File

@@ -0,0 +1 @@
export * from './edgeless-selected-rect';

View File

@@ -0,0 +1,94 @@
import type { ResizeHandle } from '@blocksuite/std/gfx';
import { html, nothing } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
export enum HandleDirection {
Bottom = 'bottom',
BottomLeft = 'bottom-left',
BottomRight = 'bottom-right',
Left = 'left',
Right = 'right',
Top = 'top',
TopLeft = 'top-left',
TopRight = 'top-right',
}
function ResizeHandleRenderer(
handle: ResizeHandle,
rotatable: boolean,
onPointerDown?: (e: PointerEvent, direction: ResizeHandle) => void,
getCursor?: (options: {
type: 'resize' | 'rotate';
handle: ResizeHandle;
}) => string
) {
const handlerPointerDown = (e: PointerEvent) => {
e.stopPropagation();
onPointerDown && onPointerDown(e, handle);
};
const rotationTpl =
handle.length > 6 && rotatable
? html`<div
class="rotate"
style=${styleMap({
cursor: getCursor
? getCursor({
type: 'rotate',
handle,
})
: 'default',
})}
></div>`
: nothing;
return html`<div
class="handle"
aria-label=${handle}
@pointerdown=${handlerPointerDown}
>
${rotationTpl}
<div
class="resize transparent-handle"
style=${styleMap({
cursor: getCursor
? getCursor({
type: 'resize',
handle,
})
: 'default',
})}
></div>
</div>`;
}
/**
* Indicate how selected elements can be resized.
*
* - edge: The selected elements can only be resized dragging edge, usually when note element is selected
* - all: The selected elements can be resize both dragging edge or corner, usually when all elements are `shape`
* - none: The selected elements can't be resized, usually when all elements are `connector`
* - corner: The selected elements can only be resize dragging corner, this is by default mode
* - edgeAndCorner: The selected elements can be resize both dragging left right edge or corner, usually when all elements are 'text'
*/
export type ResizeMode = 'edge' | 'all' | 'none' | 'corner' | 'edgeAndCorner';
export function RenderResizeHandles(
resizeHandles: ResizeHandle[],
rotatable: boolean,
onPointerDown: (e: PointerEvent, direction: ResizeHandle) => void,
getCursor?: (options: {
type: 'resize' | 'rotate';
handle: ResizeHandle;
}) => string
) {
return html`
${repeat(
resizeHandles,
handle => handle,
handle =>
ResizeHandleRenderer(handle, rotatable, onPointerDown, getCursor)
)}
`;
}

View File

@@ -0,0 +1,10 @@
import { WidgetViewExtension } from '@blocksuite/std';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { EDGELESS_SELECTED_RECT_WIDGET } from './edgeless-selected-rect';
export const edgelessSelectedRectWidget = WidgetViewExtension(
'affine:page',
EDGELESS_SELECTED_RECT_WIDGET,
literal`${unsafeStatic(EDGELESS_SELECTED_RECT_WIDGET)}`
);

View File

@@ -0,0 +1,416 @@
import {
EdgelessCRUDIdentifier,
type Options,
Overlay,
type RoughCanvas,
} from '@blocksuite/affine-block-surface';
import { type Shape, ShapeFactory } from '@blocksuite/affine-gfx-shape';
import {
type Connection,
getShapeRadius,
getShapeType,
GroupElementModel,
type NoteBlockModel,
ShapeElementModel,
type ShapeName,
type ShapeStyle,
} from '@blocksuite/affine-model';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound, normalizeDegAngle, type XYWH } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import type { BlockComponent } from '@blocksuite/std';
import type {
CursorType,
GfxController,
GfxModel,
ResizeHandle,
StandardCursor,
} from '@blocksuite/std/gfx';
import * as Y from 'yjs';
export enum Direction {
Right,
Bottom,
Left,
Top,
}
export const PANEL_WIDTH = 136;
export const PANEL_HEIGHT = 108;
export const MAIN_GAP = 100;
export const SECOND_GAP = 20;
export const DEFAULT_NOTE_OVERLAY_HEIGHT = 110;
export const DEFAULT_TEXT_WIDTH = 116;
export const DEFAULT_TEXT_HEIGHT = 24;
export type TARGET_SHAPE_TYPE = ShapeName;
export type AUTO_COMPLETE_TARGET_TYPE =
| TARGET_SHAPE_TYPE
| 'text'
| 'note'
| 'frame';
class AutoCompleteTargetOverlay extends Overlay {
xywh: XYWH;
constructor(gfx: GfxController, xywh: XYWH) {
super(gfx);
this.xywh = xywh;
}
override render(_ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {}
}
export class AutoCompleteTextOverlay extends AutoCompleteTargetOverlay {
constructor(gfx: GfxController, xywh: XYWH) {
super(gfx, xywh);
}
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
const [x, y, w, h] = this.xywh;
ctx.globalAlpha = 0.4;
ctx.strokeStyle = '#1e96eb';
ctx.lineWidth = 1;
ctx.strokeRect(x, y, w, h);
// fill text placeholder
ctx.font = '15px sans-serif';
ctx.fillStyle = '#C0BFC1';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText("Type '/' to insert", x + w / 2, y + h / 2);
}
}
export class AutoCompleteNoteOverlay extends AutoCompleteTargetOverlay {
private readonly _background: string;
constructor(gfx: GfxController, xywh: XYWH, background: string) {
super(gfx, xywh);
this._background = background;
}
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
const [x, y, w, h] = this.xywh;
ctx.globalAlpha = 0.4;
ctx.fillStyle = this._background;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.10)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(x, y, w, h, 8);
ctx.closePath();
ctx.fill();
ctx.stroke();
// fill text placeholder
ctx.font = '15px sans-serif';
ctx.fillStyle = 'black';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText("Type '/' for command", x + 24, y + h / 2);
}
}
export class AutoCompleteFrameOverlay extends AutoCompleteTargetOverlay {
private readonly _strokeColor;
constructor(gfx: GfxController, xywh: XYWH, strokeColor: string) {
super(gfx, xywh);
this._strokeColor = strokeColor;
}
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
const [x, y, w, h] = this.xywh;
// frame title background
const titleWidth = 72;
const titleHeight = 30;
const titleY = y - titleHeight - 10;
ctx.globalAlpha = 0.4;
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.beginPath();
ctx.roundRect(x, titleY, titleWidth, titleHeight, 4);
ctx.closePath();
ctx.fill();
// fill title text
ctx.globalAlpha = 1;
ctx.font = '14px sans-serif';
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Frame', x + titleWidth / 2, titleY + titleHeight / 2);
// frame stroke
ctx.globalAlpha = 0.4;
ctx.strokeStyle = this._strokeColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(x, y, w, h, 8);
ctx.closePath();
ctx.stroke();
}
}
export class AutoCompleteShapeOverlay extends Overlay {
private readonly _shape: Shape;
constructor(
gfx: GfxController,
xywh: XYWH,
type: TARGET_SHAPE_TYPE,
options: Options,
shapeStyle: ShapeStyle
) {
super(gfx);
this._shape = ShapeFactory.createShape(xywh, type, options, shapeStyle);
}
override render(ctx: CanvasRenderingContext2D, rc: RoughCanvas) {
ctx.globalAlpha = 0.4;
this._shape.draw(ctx, rc);
}
}
export function nextBound(
type: Direction,
curShape: ShapeElementModel,
elements: ShapeElementModel[]
) {
const bound = Bound.deserialize(curShape.xywh);
const { x, y, w, h } = bound;
let nextBound: Bound;
let angle = 0;
switch (type) {
case Direction.Right:
angle = 0;
break;
case Direction.Bottom:
angle = 90;
break;
case Direction.Left:
angle = 180;
break;
case Direction.Top:
angle = 270;
break;
}
angle = normalizeDegAngle(angle + curShape.rotate);
if (angle >= 45 && angle <= 135) {
nextBound = new Bound(x, y + h + MAIN_GAP, w, h);
} else if (angle >= 135 && angle <= 225) {
nextBound = new Bound(x - w - MAIN_GAP, y, w, h);
} else if (angle >= 225 && angle <= 315) {
nextBound = new Bound(x, y - h - MAIN_GAP, w, h);
} else {
nextBound = new Bound(x + w + MAIN_GAP, y, w, h);
}
function isValidBound(bound: Bound) {
return !elements.some(a => bound.isOverlapWithBound(a.elementBound));
}
let count = 0;
function findValidBound() {
count++;
const number = Math.ceil(count / 2);
const next = nextBound.clone();
switch (type) {
case Direction.Right:
case Direction.Left:
next.y =
count % 2 === 1
? nextBound.y - (h + SECOND_GAP) * number
: nextBound.y + (h + SECOND_GAP) * number;
break;
case Direction.Bottom:
case Direction.Top:
next.x =
count % 2 === 1
? nextBound.x - (w + SECOND_GAP) * number
: nextBound.x + (w + SECOND_GAP) * number;
break;
}
if (isValidBound(next)) return next;
return findValidBound();
}
return isValidBound(nextBound) ? nextBound : findValidBound();
}
export function getPosition(type: Direction) {
let startPosition: Connection['position'];
let endPosition: Connection['position'];
switch (type) {
case Direction.Right:
startPosition = [1, 0.5];
endPosition = [0, 0.5];
break;
case Direction.Bottom:
startPosition = [0.5, 1];
endPosition = [0.5, 0];
break;
case Direction.Left:
startPosition = [0, 0.5];
endPosition = [1, 0.5];
break;
case Direction.Top:
startPosition = [0.5, 0];
endPosition = [0.5, 1];
break;
}
return { startPosition, endPosition };
}
export function isShape(element: unknown): element is ShapeElementModel {
return element instanceof ShapeElementModel;
}
export function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function createEdgelessElement(
edgeless: BlockComponent,
current: ShapeElementModel | NoteBlockModel,
bound: Bound
) {
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
let id;
let element: GfxModel | null = null;
if (isShape(current)) {
id = crud.addElement(current.type, {
...current.serialize(),
text: new Y.Text(),
xywh: bound.serialize(),
});
if (!id) return null;
element = crud.getElementById(id);
} else {
const { store } = edgeless;
id = store.addBlock(
'affine:note',
{
background: current.props.background,
displayMode: current.props.displayMode,
edgeless: current.props.edgeless,
xywh: bound.serialize(),
},
edgeless.model.id
);
const note = store.getBlock(id)?.model;
if (!note) {
throw new BlockSuiteError(
ErrorCode.GfxBlockElementError,
'Note block is not found after creation'
);
}
assertType<NoteBlockModel>(note);
store.updateBlock(note, () => {
note.props.edgeless.collapse = true;
});
store.addBlock('affine:paragraph', {}, note.id);
element = note;
}
if (!element) {
throw new BlockSuiteError(
ErrorCode.GfxBlockElementError,
'Element is not found after creation'
);
}
const group = current.group;
if (group instanceof GroupElementModel) {
group.addChild(element);
}
return id;
}
export function createShapeElement(
edgeless: BlockComponent,
current: ShapeElementModel | NoteBlockModel,
targetType: TARGET_SHAPE_TYPE
) {
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
const id = crud.addElement('shape', {
shapeType: getShapeType(targetType),
radius: getShapeRadius(targetType),
text: new Y.Text(),
});
if (!id) return null;
const element = crud.getElementById(id);
const group = current.group;
if (group instanceof GroupElementModel && element) {
group.addChild(element);
}
return id;
}
const rotateCursorMap: {
[key in ResizeHandle]: number;
} = {
'top-right': 0,
'bottom-right': 90,
'bottom-left': 180,
'top-left': 270,
// not used
left: 0,
right: 0,
top: 0,
bottom: 0,
};
export function generateCursorUrl(
angle = 0,
handle: ResizeHandle,
fallback: StandardCursor = 'default'
): CursorType {
angle = ((angle % 360) + 360) % 360;
return `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg transform='rotate(${rotateCursorMap[handle] + angle} 16 16)'%3E%3Cpath fill='white' d='M13.7,18.5h3.9l0-1.5c0-1.4-1.2-2.6-2.6-2.6h-1.5v3.9l-5.8-5.8l5.8-5.8v3.9h2.3c3.1,0,5.6,2.5,5.6,5.6v2.3h3.9l-5.8,5.8L13.7,18.5z'/%3E%3Cpath d='M20.4,19.4v-3.2c0-2.6-2.1-4.7-4.7-4.7h-3.2l0,0V9L9,12.6l3.6,3.6v-2.6l0,0H15c1.9,0,3.5,1.6,3.5,3.5v2.4l0,0h-2.6l3.6,3.6l3.6-3.6L20.4,19.4L20.4,19.4z'/%3E%3C/g%3E%3C/svg%3E") 16 16, ${fallback}`;
}
const handleToRotateMap: {
[key in ResizeHandle]: number;
} = {
'top-left': 45,
'top-right': 135,
'bottom-right': 45,
'bottom-left': 135,
left: 0,
right: 0,
top: 90,
bottom: 90,
};
const rotateToHandleMap: {
[key: number]: StandardCursor;
} = {
0: 'ew-resize',
45: 'nwse-resize',
90: 'ns-resize',
135: 'nesw-resize',
};
export function getRotatedResizeCursor(option: {
handle: ResizeHandle;
angle: number;
}) {
const angle =
(Math.round(
(handleToRotateMap[option.handle] + ((option.angle + 360) % 360)) / 45
) %
4) *
45;
return rotateToHandleMap[angle] || 'default';
}

View File

@@ -0,0 +1,23 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
import { edgelessSelectedRectWidget } from './spec';
export class EdgelessSelectedRectViewExtension extends ViewExtensionProvider {
override name = 'affine-edgeless-selected-rect-widget';
override effect() {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
if (this.isEdgeless(context.scope)) {
context.register(edgelessSelectedRectWidget);
}
}
}

View File

@@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/frame" },
{ "path": "../../blocks/surface" },
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../gfx/connector" },
{ "path": "../../gfx/shape" },
{ "path": "../../gfx/text" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" }
]
}

View File

@@ -0,0 +1,41 @@
{
"name": "@blocksuite/affine-widget-edgeless-toolbar",
"description": "Affine edgeless toolbar widget.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"rxjs": "^7.8.2"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,192 @@
import type { Placement } from '@floating-ui/dom';
import type { TemplateResult } from 'lit';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { cache } from 'lit/directives/cache.js';
import { styleMap } from 'lit/directives/style-map.js';
export class EdgelessToolIconButton extends LitElement {
static override styles = css`
.icon-container {
position: relative;
display: flex;
align-items: center;
padding: var(--icon-container-padding);
color: var(--affine-icon-color);
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
box-sizing: border-box;
width: var(--icon-container-width, unset);
justify-content: var(--justify, unset);
}
.icon-container.active-mode-color[active] {
color: var(--affine-primary-color);
}
.icon-container.active-mode-background[active] {
background: var(--affine-hover-color);
}
.icon-container[disabled] {
pointer-events: none;
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
.icon-container[coming] {
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
::slotted(svg) {
flex-shrink: 0;
width: var(--icon-size, unset);
height: var(--icon-size, unset);
}
::slotted(.label) {
flex: 1;
padding: 0 4px;
overflow: hidden;
white-space: nowrap;
line-height: var(--label-height, inherit);
}
::slotted(.label.padding0) {
padding: 0;
}
::slotted(.label.ellipsis) {
text-overflow: ellipsis;
}
::slotted(.label.medium) {
font-weight: 500;
}
.icon-container[with-hover]::before {
content: '';
display: block;
background: var(--affine-hover-color);
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
border-radius: 4px;
}
`;
constructor() {
super();
this.addEventListener(
'click',
event => {
if (this.disabled) {
event.stopPropagation();
event.preventDefault();
}
},
{ capture: true }
);
}
override connectedCallback() {
super.connectedCallback();
this.role = 'button';
}
override render() {
const tooltip = this.coming ? '(Coming soon)' : this.tooltip;
const classnames = `icon-container active-mode-${this.activeMode} ${this.hoverState ? 'hovered' : ''}`;
const padding = this.iconContainerPadding;
const iconContainerStyles = styleMap({
'--icon-container-width': this.iconContainerWidth,
'--icon-container-padding': Array.isArray(padding)
? padding.map(v => `${v}px`).join(' ')
: `${padding}px`,
'--icon-size': this.iconSize,
'--justify': this.justify,
'--label-height': this.labelHeight,
});
return html`
<style>
.icon-container:hover,
.icon-container.hovered {
background: ${this.hover ? `var(--affine-hover-color)` : 'inherit'};
}
</style>
<div
class=${classnames}
style=${iconContainerStyles}
?with-hover=${this.withHover}
?disabled=${this.disabled}
?active=${this.active}
>
<slot></slot>
${cache(
this.showTooltip && tooltip
? html`<affine-tooltip
tip-position=${this.tipPosition}
.arrow=${this.arrow}
.offset=${this.tooltipOffset}
>${tooltip}</affine-tooltip
>`
: nothing
)}
</div>
`;
}
@property({ attribute: false })
accessor active = false;
@property({ attribute: false })
accessor activeMode: 'color' | 'background' = 'color';
@property({ attribute: false })
accessor arrow = true;
@property({ attribute: false })
accessor coming = false;
@property({ attribute: false })
accessor disabled = false;
@property({ attribute: false })
accessor hover = true;
@property({ attribute: false })
accessor hoverState = false;
@property({ attribute: false })
accessor iconContainerPadding: number | number[] = 2;
@property({ attribute: false })
accessor iconContainerWidth: string | undefined = undefined;
@property({ attribute: false })
accessor iconSize: string | undefined = undefined;
@property({ attribute: false })
accessor justify: string | undefined = undefined;
@property({ attribute: false })
accessor labelHeight: string | undefined = undefined;
@property({ type: Boolean })
accessor showTooltip = true;
@property({ attribute: false })
accessor tipPosition: Placement = 'top';
@property({ attribute: false })
accessor tooltip!: string | TemplateResult<1>;
@property({ attribute: false })
accessor tooltipOffset = 8;
@property({ attribute: false })
accessor withHover: boolean | undefined = undefined;
}

View File

@@ -0,0 +1,39 @@
import { css, html } from 'lit';
import { EdgelessToolIconButton } from './tool-icon-button.js';
export class EdgelessToolbarButton extends EdgelessToolIconButton {
static override styles = css`
.icon-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--affine-icon-color);
cursor: pointer;
}
.icon-container.active-mode-color[active] {
color: var(--affine-primary-color);
}
.icon-container.active-mode-background[active] {
background: var(--affine-hover-color);
}
.icon-container[disabled] {
pointer-events: none;
cursor: not-allowed;
}
.icon-container[coming] {
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
`;
override render() {
return html` ${super.render()} `;
}
}

View File

@@ -0,0 +1,16 @@
import { StyleGeneralIcon, StyleScribbleIcon } from '@blocksuite/icons/lit';
import type { MenuItem } from './types';
export const LINE_STYLE_LIST = [
{
key: 'General',
value: false,
icon: StyleGeneralIcon(),
},
{
key: 'Scribbled',
value: true,
icon: StyleScribbleIcon(),
},
] as const satisfies MenuItem<boolean>[];

View File

@@ -0,0 +1,3 @@
export * from './consts.js';
export * from './types.js';
export * from './utils.js';

View File

@@ -0,0 +1,28 @@
import type { MenuConfig } from '@blocksuite/affine-components/context-menu';
import type { BlockComponent } from '@blocksuite/std';
import type { GfxController } from '@blocksuite/std/gfx';
import type { TemplateResult } from 'lit';
export type MenuItem<T> = {
key?: string;
value: T;
icon?: TemplateResult;
disabled?: boolean;
};
export type Menu<T> = {
label: string;
icon?: TemplateResult;
tooltip?: string;
items: MenuItem<T>[];
currentValue: T;
onPick: (value: T) => void;
};
/**
* Helper function to build a menu configuration for a tool in dense mode
*/
export type DenseMenuBuilder = (
edgeless: BlockComponent,
gfx: GfxController
) => MenuConfig;

View File

@@ -0,0 +1,72 @@
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
import type { ToolbarContext } from '@blocksuite/affine-shared/services';
import type { BlockComponent } from '@blocksuite/std';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { repeat } from 'lit/directives/repeat.js';
import type { Menu, MenuItem } from './types';
export function renderCurrentMenuItemWith<T, F extends keyof MenuItem<T>>(
items: MenuItem<T>[],
currentValue: T,
field: F
) {
return items.find(({ value }) => value === currentValue)?.[field];
}
export function renderMenu<T>({
label,
tooltip,
icon,
items,
currentValue,
onPick,
}: Menu<T>) {
return html`
<editor-menu-button
aria-label="${`${label.toLowerCase()}-menu`}"
.button=${html`
<editor-icon-button
aria-label="${label}"
.tooltip="${tooltip ?? label}"
>
${icon ?? renderCurrentMenuItemWith(items, currentValue, 'icon')}
${EditorChevronDown}
</editor-icon-button>
`}
>
${renderMenuItems(items, currentValue, onPick)}
</editor-menu-button>
`;
}
export function renderMenuItems<T>(
items: MenuItem<T>[],
currentValue: T,
onPick: (value: T) => void
) {
return repeat(
items,
item => item.value,
({ key, value, icon, disabled }) => html`
<editor-icon-button
aria-label="${ifDefined(key)}"
.disabled=${ifDefined(disabled)}
.tooltip="${ifDefined(key)}"
.active="${currentValue === value}"
.activeMode="${'background'}"
@click=${() => onPick(value)}
>
${icon}
</editor-icon-button>
`
);
}
export function getRootBlock(ctx: ToolbarContext): BlockComponent | null {
const rootModel = ctx.store.root;
if (!rootModel) return null;
return ctx.view.getBlock(rootModel.id);
}

View File

@@ -0,0 +1,21 @@
import type { ColorScheme } from '@blocksuite/affine-model';
import { createContext } from '@lit/context';
import type { Subject } from 'rxjs';
import type { EdgelessToolbarWidget } from './edgeless-toolbar.js';
export interface EdgelessToolbarSlots {
resize: Subject<{ w: number; h: number }>;
}
export const edgelessToolbarSlotsContext = createContext<EdgelessToolbarSlots>(
Symbol('edgelessToolbarSlotsContext')
);
export const edgelessToolbarThemeContext = createContext<ColorScheme>(
Symbol('edgelessToolbarThemeContext')
);
export const edgelessToolbarContext = createContext<EdgelessToolbarWidget>(
Symbol('edgelessToolbarContext')
);

View File

@@ -0,0 +1,103 @@
import { BlockSuiteError } from '@blocksuite/global/exceptions';
// more than 100% due to the shadow
const leaveToPercent = `calc(100% + 10px)`;
export interface MenuPopper<T extends HTMLElement> {
element: T;
dispose: () => void;
cancel?: () => void;
}
// store active poppers
const popMap = new WeakMap<HTMLElement, Map<string, MenuPopper<HTMLElement>>>();
function animateEnter(el: HTMLElement) {
el.style.transform = 'translateY(0)';
}
function animateLeave(el: HTMLElement) {
el.style.transform = `translateY(${leaveToPercent})`;
}
export function createPopper<T extends keyof HTMLElementTagNameMap>(
tagName: T,
reference: HTMLElement,
options?: {
/** transition duration in ms */
duration?: number;
onDispose?: () => void;
setProps?: (ele: HTMLElementTagNameMap[T]) => void;
}
): MenuPopper<HTMLElementTagNameMap[T]> {
const duration = options?.duration ?? 230;
if (!popMap.has(reference)) popMap.set(reference, new Map());
const elMap = popMap.get(reference);
// if there is already a popper, cancel leave transition and apply enter transition
if (elMap && elMap.has(tagName)) {
const popper = elMap.get(tagName);
if (popper) {
popper.cancel?.();
requestAnimationFrame(() => animateEnter(popper.element));
return popper as MenuPopper<HTMLElementTagNameMap[T]>;
}
}
const clipWrapper = document.createElement('div');
const menu = document.createElement(tagName);
options?.setProps?.(menu);
clipWrapper.append(menu);
if (!reference.shadowRoot) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.ValueNotExists,
'reference must be a shadow root'
);
}
reference.shadowRoot.append(clipWrapper);
// apply enter transition
menu.style.transition = `all ${duration}ms ease`;
animateLeave(menu);
requestAnimationFrame(() => animateEnter(menu));
Object.assign(clipWrapper.style, {
height: '100px',
pointerEvents: 'none',
position: 'absolute',
overflow: 'hidden',
width: '100%',
maxWidth: '100%',
boxSizing: 'border-box',
left: '0px',
bottom: '100%',
display: 'flex',
alignItems: 'end',
});
Object.assign(menu.style, {
width: '100%',
marginLeft: '30px',
maxWidth: 'calc(100% - 60px)',
bottom: '0%',
pointerEvents: 'auto',
});
const remove = () => {
clipWrapper.remove();
menu.remove();
popMap.get(reference)?.delete(tagName);
options?.onDispose?.();
};
const popper: MenuPopper<HTMLElementTagNameMap[T]> = {
element: menu,
dispose: () => {
// apply leave transition
animateLeave(menu);
menu.addEventListener('transitionend', remove, { once: true });
popper.cancel = () => menu.removeEventListener('transitionend', remove);
},
};
popMap.get(reference)?.set(tagName, popper);
return popper;
}

View File

@@ -0,0 +1,477 @@
import type { ShapeName } from '@blocksuite/affine-model';
import {
EditPropsStore,
ThemeProvider,
ViewportElementProvider,
} from '@blocksuite/affine-shared/services';
import { Bound } from '@blocksuite/global/gfx';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import {
type ReactiveController,
type ReactiveControllerHost,
render,
} from 'lit';
import {
type ElementDragEvent,
mouseResolver,
touchResolver,
} from './event-resolver.js';
import {
createShapeDraggingOverlay,
defaultInfo,
type DraggingInfo,
} from './overlay-factory.js';
import {
defaultIsValidMove,
type EdgelessDraggableElementHost,
type EdgelessDraggableElementOptions,
type ElementInfo,
type OverlayLayer,
} from './types.js';
interface ReactiveState<T> {
cancelled: boolean;
draggingElement: ElementInfo<T> | null;
dragOut: boolean | null;
}
interface EventCache {
onMouseUp?: (e: MouseEvent) => void;
onMouseMove?: (e: MouseEvent) => void;
onTouchMove?: (e: TouchEvent) => void;
onTouchEnd?: (e: TouchEvent) => void;
}
export class EdgelessDraggableElementController<
T,
> implements ReactiveController {
clearTimeout: ReturnType<typeof setTimeout> | null = null;
events: EventCache = {};
info = defaultInfo as DraggingInfo<T>;
overlay: OverlayLayer | null = null;
states: ReactiveState<T> = {
cancelled: false,
draggingElement: null,
dragOut: null,
};
constructor(
public host: EdgelessDraggableElementHost & ReactiveControllerHost,
public options: EdgelessDraggableElementOptions<T>
) {
host.addController(this);
}
get gfx() {
return this.options.edgeless.std.get(GfxControllerIdentifier);
}
/**
* let overlay shape animate back to the original position
*/
private _animateCancelDrop(onFinished?: () => void, duration = 230) {
const { overlay, info } = this;
if (!overlay) return;
this.options?.onCanceled?.(overlay, info.elementInfo);
// unlock pointer events
overlay.mask.style.pointerEvents = 'none';
// clip bottom
if (info.scopeRect) {
overlay.mask.style.height =
info.scopeRect.bottom - info.edgelessRect.top + 'px';
}
const { element, elementRectOriginal } = info;
const newShapeRect = element.getBoundingClientRect();
const x = newShapeRect.left - elementRectOriginal.left;
const y = newShapeRect.top - elementRectOriginal.top;
// apply a transition
overlay.element.style.transition = `transform ${duration}ms ease`;
overlay.element.style.setProperty('--translate-x', `${x}px`);
overlay.element.style.setProperty('--translate-y', `${y}px`);
overlay.transitionWrapper.style.setProperty('--scale', '1');
this.clearTimeout = setTimeout(() => {
if (onFinished) return onFinished();
this.reset();
this.removeAllEvents();
this.clearTimeout = null;
}, duration);
}
private _createOverlay({ x, y }: Pick<ElementDragEvent, 'x' | 'y'>) {
const { edgeless } = this.options;
const { elementInfo, elementRectOriginal, offsetPos, edgelessRect } =
this.info;
this.reset();
this._updateState('draggingElement', elementInfo);
this.overlay = createShapeDraggingOverlay(this.info);
const { overlay } = this;
// init shape position with 'left' and 'top';
const { width, height, left, top } = elementRectOriginal;
const relativeX = left - edgelessRect.left;
const relativeY = top - edgelessRect.top;
// make sure the transform origin is the same as the mouse position
const ox = `${(((x - left) / width) * 100).toFixed(0)}%`;
const oy = `${(((y - top) / height) * 100).toFixed(0)}%`;
Object.assign(overlay.element.style, {
left: `${relativeX}px`,
top: `${relativeY}px`,
});
overlay.element.style.setProperty('--translate-x', `${offsetPos.x}px`);
overlay.element.style.setProperty('--translate-y', `${offsetPos.y}px`);
overlay.transitionWrapper.style.transformOrigin = `${ox} ${oy}`;
const shapeName = (elementInfo as ElementInfo<{ name: ShapeName }>).data
.name;
const { fillColor, strokeColor } =
edgeless.host.std.get(EditPropsStore).lastProps$.value[
`shape:${shapeName}`
] || {};
const color = edgeless.host.std
.get(ThemeProvider)
.generateColorProperty(fillColor);
const stroke = edgeless.host.std
.get(ThemeProvider)
.generateColorProperty(strokeColor);
overlay.element.style.setProperty('color', color);
overlay.element.style.setProperty('stroke', stroke);
// lifecycle hook
this.options.onOverlayCreated?.(overlay, elementInfo);
}
private _onDragEnd() {
const { overlay, info, options } = this;
const { startTime, elementInfo, edgelessRect, validMoved } = info;
const { clickThreshold = 1500 } = options;
const zoom = this.gfx.viewport.zoom;
if (!validMoved) {
const duration = Date.now() - startTime;
if (duration < clickThreshold) {
options.onElementClick?.(info.elementInfo);
if (options.clickToDrag) {
this._createOverlay(info.startPos);
this.info.moved = true;
setTimeout(() => {
this._updateOverlayScale(zoom);
}, 50);
return false;
}
}
this.reset();
return true;
}
if (this.states.dragOut && !this.states.cancelled && overlay) {
const rect = overlay.transitionWrapper.getBoundingClientRect();
const [modelX, modelY] = this.gfx.viewport.toModelCoord(
rect.left - edgelessRect.left,
rect.top - edgelessRect.top
);
const bound = new Bound(
modelX,
modelY,
rect.width / zoom,
rect.height / zoom
);
options?.onDrop?.(elementInfo, bound);
this.reset();
return true;
}
if (!this.states.dragOut) this._animateCancelDrop();
return true;
}
private _onDragMove(e: ElementDragEvent) {
if (this.states.cancelled) return;
const { info, options } = this;
// first move
if (!info.moved) {
info.moved = true;
this._createOverlay(e);
}
const { overlay } = this;
if (!overlay) return;
const { x, y } = e;
const { startPos, scopeRect } = info;
const offsetX = x - startPos.x;
const offsetY = y - startPos.y;
info.offsetPos = { x: offsetX, y: offsetY };
if (!info.validMoved) {
const isValidMove = options.isValidMove ?? defaultIsValidMove;
info.validMoved = isValidMove(info.offsetPos);
}
// check if inside scopeElement
const newDragOut =
!scopeRect ||
y < scopeRect.top ||
y > scopeRect.bottom ||
x < scopeRect.left ||
x > scopeRect.right;
if (newDragOut !== this.states.dragOut)
options.onEnterOrLeaveScope?.(overlay, newDragOut);
this._updateState('dragOut', newDragOut);
// apply transform
// - move shape with translate
overlay.element.style.setProperty('--translate-x', `${offsetX}px`);
overlay.element.style.setProperty('--translate-y', `${offsetY}px`);
// - scale shape with scale
const zoom = this.gfx.viewport.zoom;
this._updateOverlayScale(zoom);
}
private _onDragStart(e: ElementDragEvent, elementInfo: ElementInfo<T>) {
const { scopeElement, edgeless } = this.options;
e.originalEvent.stopPropagation();
e.originalEvent.preventDefault();
// Safari compatibility
// Cannot get edgeless.host.getBoundingClientRect().width in Safari (Always 0)
const edgelessRect = edgeless.host.getBoundingClientRect();
if (edgelessRect.width === 0) {
const { viewport } = edgeless.std.get(ViewportElementProvider);
edgelessRect.width = viewport.clientWidth;
}
this.info = {
startTime: Date.now(),
startPos: { x: e.x, y: e.y },
offsetPos: { x: 0, y: 0 },
scopeRect: scopeElement?.getBoundingClientRect() ?? null,
edgelessRect,
elementRectOriginal: e.el.getBoundingClientRect(),
element: e.el,
elementInfo,
moved: false,
validMoved: false,
parentToMount: edgeless.host,
};
this.removeAllEvents();
if (e.inputType === 'mouse') {
const onMouseMove = (e: MouseEvent) => {
this._onDragMove(mouseResolver(e));
};
const onMouseUp = (_: MouseEvent) => {
const finished = this._onDragEnd();
if (finished) {
edgeless.host.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
};
edgeless.host.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
this.events = { onMouseMove, onMouseUp };
} else {
const onTouchMove = (e: TouchEvent) => {
this._onDragMove(touchResolver(e));
};
const onTouchEnd = (_: TouchEvent) => {
const finished = this._onDragEnd();
if (finished) {
edgeless.host.removeEventListener('touchmove', onTouchMove);
window.removeEventListener('touchend', onTouchEnd);
}
};
edgeless.host.addEventListener('touchmove', onTouchMove);
window.addEventListener('touchend', onTouchEnd);
this.events = { onTouchMove, onTouchEnd };
}
}
/**
* Update overlay shape scale according to the current zoom level
*/
private _updateOverlayScale(zoom: number) {
const transitionWrapper = this.overlay?.transitionWrapper;
if (!transitionWrapper) return;
const standardWidth =
this.info.elementInfo.standardWidth ?? this.options.standardWidth ?? 100;
const { elementRectOriginal } = this.info;
const scale = (standardWidth * zoom) / elementRectOriginal.width;
const clickToDragScale = this.options.clickToDragScale ?? 1.2;
const finalScale = this.states.dragOut
? scale
: this.options.clickToDrag
? clickToDragScale
: 1;
transitionWrapper.style.setProperty('--scale', finalScale.toFixed(2));
}
/**
* @internal
*/
private _updateState<Key extends keyof ReactiveState<T>>(
key: Key,
value: ReactiveState<T>[Key]
) {
this.states[key] = value;
this.host.requestUpdate();
}
private _updateStates(states: Partial<ReactiveState<T>>) {
Object.assign(this.states, states);
this.host.requestUpdate();
}
/**
* Cancel the current dragging & animate even if dragOut
*/
cancel() {
if (this.states.cancelled) return;
this._updateState('cancelled', true);
this._animateCancelDrop();
}
/**
* Same as {@link cancel} but without animation
*/
cancelWithoutAnimation() {
if (this.states.cancelled) return;
this._updateState('cancelled', true);
this.reset();
this.removeAllEvents();
}
/**
* A workaround to apply click event manually
*/
clickToDrag(target: HTMLElement, startPos: { x: number; y: number }) {
if (!this.options.clickToDrag) {
this.options.clickToDrag = true;
console.warn(
'clickToDrag is not enabled, it will be enabled automatically'
);
}
const targetRect = target.getBoundingClientRect();
const targetCenter = {
x: targetRect.left + targetRect.width / 2,
y: targetRect.top + targetRect.height / 2,
};
const mouseDownEvent = new MouseEvent('mousedown', {
clientX: targetCenter.x,
clientY: targetCenter.y,
});
const mouseUpEvent = new MouseEvent('mouseup', {
clientX: targetCenter.x,
clientY: targetCenter.y,
});
target.dispatchEvent(mouseDownEvent);
window.dispatchEvent(mouseUpEvent);
const mouseMoveEvent = new MouseEvent('mousemove', {
clientX: startPos.x,
clientY: startPos.y,
});
this.options.edgeless.host.dispatchEvent(mouseMoveEvent);
}
dragAndMoveTo(target: HTMLElement, to: { x: number; y: number }) {
const targetRect = target.getBoundingClientRect();
const targetCenter = {
x: targetRect.left + targetRect.width / 2,
y: targetRect.top + targetRect.height / 2,
};
const mouseDownEvent = new MouseEvent('mousedown', {
clientX: targetCenter.x,
clientY: targetCenter.y,
});
const mouseMoveStartEvent = new MouseEvent('mousemove', {
clientX: targetCenter.x,
clientY: targetCenter.y,
});
const mouseMoveToEvent = new MouseEvent('mousemove', {
clientX: to.x,
clientY: to.y,
});
target.dispatchEvent(mouseDownEvent);
this.options.edgeless.host.dispatchEvent(mouseMoveStartEvent);
this.options.edgeless.host.dispatchEvent(mouseMoveToEvent);
}
hostConnected() {
this.host.disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(({ zoom }) => {
this._updateOverlayScale(zoom);
})
);
this.host.disposables.addFromEvent(
window,
'keydown',
(e: KeyboardEvent) => {
if (e.key === 'Escape' && this.states.draggingElement) this.cancel();
}
);
}
hostDisconnected() {
this.removeAllEvents();
this.reset();
}
onMouseDown(e: MouseEvent, elementInfo: ElementInfo<T>) {
this._onDragStart(mouseResolver(e), elementInfo);
}
onTouchStart(e: TouchEvent, elementInfo: ElementInfo<T>) {
this._onDragStart(touchResolver(e), elementInfo);
}
removeAllEvents() {
const { events, options } = this;
const host = options.edgeless.host;
const { onMouseUp, onMouseMove, onTouchMove, onTouchEnd } = events;
onMouseUp && window.removeEventListener('mouseup', onMouseUp);
onMouseMove && host && host.removeEventListener('mousemove', onMouseMove);
onTouchMove && host && host.removeEventListener('touchmove', onTouchMove);
onTouchEnd && window.removeEventListener('touchend', onTouchEnd);
this.events = {};
}
reset() {
if (this.clearTimeout) clearTimeout(this.clearTimeout);
this.overlay?.mask.remove();
this.overlay = null;
this._updateStates({
cancelled: false,
draggingElement: null,
dragOut: null,
});
}
updateElementInfo(elementInfo: Partial<ElementInfo<T>>) {
this.info.elementInfo = {
...this.info.elementInfo,
...elementInfo,
};
if (elementInfo.preview && this.overlay) {
render(elementInfo.preview, this.overlay.transitionWrapper);
}
}
}

View File

@@ -0,0 +1,25 @@
export type ElementDragEvent = {
inputType: 'mouse' | 'touch';
x: number;
y: number;
el: HTMLElement;
originalEvent: MouseEvent | TouchEvent;
};
export const touchResolver = (event: TouchEvent) =>
({
inputType: 'touch',
x: event.touches[0].clientX,
y: event.touches[0].clientY,
el: event.currentTarget as HTMLElement,
originalEvent: event,
}) satisfies ElementDragEvent;
export const mouseResolver = (event: MouseEvent) =>
({
inputType: 'mouse',
x: event.clientX,
y: event.clientY,
el: event.currentTarget as HTMLElement,
originalEvent: event,
}) satisfies ElementDragEvent;

View File

@@ -0,0 +1 @@
export * from './draggable-element.controller.js';

View File

@@ -0,0 +1,96 @@
import { render } from 'lit';
import type { ElementInfo, OverlayLayer } from './types.js';
export type DraggingInfo<T> = {
startPos: { x: number; y: number };
offsetPos: { x: number; y: number };
startTime: number;
scopeRect: DOMRect | null;
edgelessRect: DOMRect;
elementRectOriginal: DOMRect;
element: HTMLElement;
elementInfo: ElementInfo<T>;
parentToMount: HTMLElement;
moved: boolean;
validMoved: boolean;
};
export const defaultInfo = {
startPos: { x: 0, y: 0 },
offsetPos: { x: 0, y: 0 },
startTime: 0,
scopeRect: {} as DOMRect,
edgelessRect: {} as DOMRect,
elementRectOriginal: {} as DOMRect,
element: null as unknown as HTMLElement,
elementInfo: null as unknown as ElementInfo<unknown>,
parentToMount: null as unknown as HTMLElement,
moved: false,
validMoved: false,
} satisfies DraggingInfo<unknown>;
const className = (name: string) =>
`edgeless-draggable-control-overlay-${name}`;
const addClass = (node: HTMLElement, name: string) =>
node.classList.add(className(name));
export const createShapeDraggingOverlay = <T>(
info: DraggingInfo<T>
): OverlayLayer => {
const { edgelessRect, parentToMount, element: originalElement } = info;
const elementStyle = getComputedStyle(originalElement);
const mask = document.createElement('div');
addClass(mask, 'mask');
Object.assign(mask.style, {
position: 'absolute',
top: '0',
left: '0',
width: edgelessRect.width + 'px',
height: edgelessRect.height + 'px',
overflow: 'hidden',
zIndex: '9999',
// for debug purpose
// background: 'rgba(255, 0, 0, 0.1)',
});
const element = document.createElement('div');
addClass(element, 'element');
const transitionWrapper = document.createElement('div');
addClass(transitionWrapper, 'transition-wrapper');
Object.assign(transitionWrapper.style, {
transition: 'all 0.18s ease',
transform: 'scale(var(--scale, 1)) rotate(var(--rotate, 0deg))',
width: elementStyle.width,
height: elementStyle.height,
});
transitionWrapper.style.setProperty('--rotate', '0deg');
transitionWrapper.style.setProperty('--scale', '1');
render(info.elementInfo.preview, transitionWrapper);
Object.assign(element.style, {
transform:
'translate(var(--translate-x, 0), var(--translate-y, 0)) rotate(var(--rotate, 0deg)) scale(var(--scale, 1))',
position: 'absolute',
cursor: 'grabbing',
transition: 'inherit',
});
const styleTag = document.createElement('style');
styleTag.textContent = `
.${className('transition-wrapper')} > * {
display: block;
width: 100%;
height: 100%;
}
`;
mask.append(styleTag);
element.append(transitionWrapper);
mask.append(element);
parentToMount.append(mask);
return { mask, element, transitionWrapper };
};

View File

@@ -0,0 +1,97 @@
import type { Bound } from '@blocksuite/global/gfx';
import type { DisposableClass } from '@blocksuite/global/lit';
import type { BlockComponent } from '@blocksuite/std';
import type { TemplateResult } from 'lit';
export interface EdgelessDraggableElementHost extends DisposableClass {}
export interface OverlayLayer {
/**
* The root element of the overlay,
* used to handle clip & prevent pointer events
*/
mask: HTMLElement;
/**
* The real preview element
*/
element: HTMLElement;
/**
* The wrapper that contains the preview element,
* different from the element, this element has transition effect
*/
transitionWrapper: HTMLElement;
}
export interface EdgelessDraggableElementOptions<T> {
edgeless: BlockComponent;
/**
* In which element that the target should be dragged out
* If not provided, recognized as the drag-out whenever dragging
*/
scopeElement?: HTMLElement;
/**
* The width of the element when placed to canvas
* @default 100
*/
standardWidth?: number;
/**
* the threshold of mousedown and mouseup duration in ms
* if the duration is less than this value, it will be treated as a click
* @default 1500
*/
clickThreshold?: number;
/**
* if enabled, when clicked, will trigger drag, press ESC or reclick to cancel
*/
clickToDrag?: boolean;
/**
* the scale of the element inside {@link EdgelessDraggableElementController.scopeElement}
* when {@link EdgelessDraggableElementOptions.clickToDrag} is enabled
* @default 1.2
*/
clickToDragScale?: number;
/**
* To verify if the move is valid
*/
isValidMove?: (offset: { x: number; y: number }) => boolean;
/**
* when element is clicked - mouse down and up without moving
*/
onElementClick?: (element: ElementInfo<T>) => void;
/**
* when mouse down and moved, create overlay, customize overlay here
*/
onOverlayCreated?: (overlay: OverlayLayer, element: ElementInfo<T>) => void;
/**
* trigger when enter/leave the scope element
*/
onEnterOrLeaveScope?: (overlay: OverlayLayer, isOutside?: boolean) => void;
/**
* Drop the element on edgeless canvas
*/
onDrop?: (element: ElementInfo<T>, bound: Bound) => void;
/**
* - ESC pressed
* - or not dragged out and released
*/
onCanceled?: (overlay: OverlayLayer, element: ElementInfo<T>) => void;
}
export type ElementInfo<T> = {
// TODO: maybe make it optional, if not provided, clone event target
preview: TemplateResult;
data: T;
/**
* Override the value in {@link EdgelessDraggableElementOptions.standardWidth}
*/
standardWidth?: number;
};
export const defaultIsValidMove = (offset: { x: number; y: number }) => {
return Math.abs(offset.x) > 50 || Math.abs(offset.y) > 50;
};

View File

@@ -0,0 +1,734 @@
/* oxlint-disable typescript/no-non-null-assertion */
import {
DefaultTool,
EdgelessLegacySlotIdentifier,
} from '@blocksuite/affine-block-surface';
import {
type MenuHandler,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import {
darkToolbarStyles,
lightToolbarStyles,
} from '@blocksuite/affine-components/toolbar';
import { ColorScheme, type RootBlockModel } from '@blocksuite/affine-model';
import {
EditPropsStore,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import {
ArrowLeftSmallIcon,
ArrowRightSmallIcon,
MoreHorizontalIcon,
} from '@blocksuite/icons/lit';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { autoPlacement, offset } from '@floating-ui/dom';
import { ContextProvider } from '@lit/context';
import { computed } from '@preact/signals-core';
import { baseTheme, cssVar } from '@toeverything/theme';
import { css, html, nothing, unsafeCSS } from 'lit';
import { query, state } from 'lit/decorators.js';
import { cache } from 'lit/directives/cache.js';
import { literal, unsafeStatic } from 'lit/static-html.js';
import debounce from 'lodash-es/debounce';
import { Subject } from 'rxjs';
import {
edgelessToolbarContext,
type EdgelessToolbarSlots,
edgelessToolbarSlotsContext,
edgelessToolbarThemeContext,
} from './context.js';
import type { MenuPopper } from './create-popper.js';
import {
QuickToolIdentifier,
SeniorToolIdentifier,
} from './extension/index.js';
const TOOLBAR_PADDING_X = 12;
const TOOLBAR_HEIGHT = 64;
const QUICK_TOOLS_GAP = 10;
const QUICK_TOOL_SIZE = 36;
const QUICK_TOOL_MORE_SIZE = 20;
const SENIOR_TOOLS_GAP = 0;
const SENIOR_TOOL_WIDTH = 96;
const SENIOR_TOOL_NAV_SIZE = 20;
const DIVIDER_WIDTH = 8;
const DIVIDER_SPACE = 8;
const SAFE_AREA_WIDTH = 64;
export const EDGELESS_TOOLBAR_WIDGET = 'edgeless-toolbar-widget';
export class EdgelessToolbarWidget extends WidgetComponent<RootBlockModel> {
static override styles = css`
:host {
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
position: absolute;
z-index: 1;
left: calc(50%);
transform: translateX(-50%);
bottom: 0;
-webkit-user-select: none;
user-select: none;
width: 100%;
pointer-events: none;
}
.edgeless-toolbar-wrapper {
width: 100%;
display: flex;
justify-content: center;
}
${unsafeCSS(lightToolbarStyles('.edgeless-toolbar-wrapper'))}
${unsafeCSS(darkToolbarStyles('.edgeless-toolbar-wrapper'))}
.edgeless-toolbar-toggle-control {
pointer-events: auto;
padding-bottom: 16px;
width: fit-content;
max-width: calc(100% - ${unsafeCSS(SAFE_AREA_WIDTH)}px * 2);
min-width: 264px;
}
.edgeless-toolbar-toggle-control[data-enable='true'] {
transition: 0.23s ease;
padding-top: 100px;
transform: translateY(100px);
}
.edgeless-toolbar-toggle-control[data-enable='true']:hover {
padding-top: 0;
transform: translateY(0);
}
.edgeless-toolbar-smooth-corner {
display: block;
width: fit-content;
max-width: 100%;
}
.edgeless-toolbar-container {
position: relative;
display: flex;
align-items: center;
padding: 0 ${unsafeCSS(TOOLBAR_PADDING_X)}px;
height: ${unsafeCSS(TOOLBAR_HEIGHT)}px;
}
:host([disabled]) .edgeless-toolbar-container {
pointer-events: none;
}
.edgeless-toolbar-container[level='second'] {
position: absolute;
bottom: 8px;
transform: translateY(-100%);
}
.edgeless-toolbar-container[hidden] {
display: none;
}
.quick-tools {
display: flex;
align-items: center;
justify-content: center;
gap: ${unsafeCSS(QUICK_TOOLS_GAP)}px;
}
.full-divider {
width: ${unsafeCSS(DIVIDER_WIDTH)}px;
height: 100%;
margin: 0 ${unsafeCSS(DIVIDER_SPACE)}px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.full-divider::after {
content: '';
display: block;
width: 1px;
height: 100%;
background-color: var(--affine-border-color);
}
.pen-and-eraser {
display: flex;
height: 100%;
gap: 4px;
justify-content: center;
}
.senior-tools {
display: flex;
align-items: center;
justify-content: flex-start;
gap: ${unsafeCSS(SENIOR_TOOLS_GAP)}px;
height: 100%;
min-width: ${unsafeCSS(SENIOR_TOOL_WIDTH)}px;
}
.quick-tool-item {
width: ${unsafeCSS(QUICK_TOOL_SIZE)}px;
height: ${unsafeCSS(QUICK_TOOL_SIZE)}px;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
}
.quick-tool-more {
width: 0;
height: ${unsafeCSS(QUICK_TOOL_SIZE)}px;
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.23s ease;
overflow: hidden;
}
[data-dense-quick='true'] .quick-tool-more {
width: ${unsafeCSS(QUICK_TOOL_MORE_SIZE)}px;
margin-left: ${unsafeCSS(DIVIDER_SPACE)}px;
}
.quick-tool-more-button {
padding: 0;
}
.senior-tool-item {
width: ${unsafeCSS(SENIOR_TOOL_WIDTH)}px;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
}
.senior-nav-button-wrapper {
flex-shrink: 0;
width: 0px;
height: ${unsafeCSS(SENIOR_TOOL_NAV_SIZE)}px;
transition: width 0.23s ease;
overflow: hidden;
}
.senior-nav-button {
padding: 0;
}
.senior-nav-button svg {
width: 20px;
height: 20px;
}
[data-dense-senior='true'] .senior-nav-button-wrapper {
width: ${unsafeCSS(SENIOR_TOOL_NAV_SIZE)}px;
}
[data-dense-senior='true'] .senior-nav-button-wrapper.prev {
margin-right: ${unsafeCSS(DIVIDER_SPACE)}px;
}
[data-dense-senior='true'] .senior-nav-button-wrapper.next {
margin-left: ${unsafeCSS(DIVIDER_SPACE)}px;
}
.transform-button svg {
transition: 0.3s ease-in-out;
}
.transform-button:hover svg {
transform: scale(1.15);
}
`;
private readonly _appTheme$ = computed(() => {
return this.std.get(ThemeProvider).app$.value;
});
private _moreQuickToolsMenu: MenuHandler | null = null;
private _moreQuickToolsMenuRef: HTMLElement | null = null;
@state()
accessor containerWidth = 1920;
private readonly _onContainerResize = debounce(
({ w }: { w: number }) => {
if (!this.isConnected) return;
this.slots.resize.next({ w, h: TOOLBAR_HEIGHT });
this.containerWidth = w;
if (this._denseSeniorTools) {
this.scrollSeniorToolIndex = Math.min(
this._seniorTools.length - this.scrollSeniorToolSize,
this.scrollSeniorToolIndex
);
} else {
this.scrollSeniorToolIndex = 0;
}
if (
this._denseQuickTools &&
this._moreQuickToolsMenu &&
this._moreQuickToolsMenuRef
) {
this._moreQuickToolsMenu.close();
this._openMoreQuickToolsMenu({
currentTarget: this._moreQuickToolsMenuRef,
});
}
if (!this._denseQuickTools && this._moreQuickToolsMenu) {
this._moreQuickToolsMenu.close();
this._moreQuickToolsMenu = null;
}
},
300,
{ leading: true }
);
private _resizeObserver: ResizeObserver | null = null;
private readonly _slotsProvider = new ContextProvider(this, {
context: edgelessToolbarSlotsContext,
initialValue: { resize: new Subject() } satisfies EdgelessToolbarSlots,
});
private readonly _themeProvider = new ContextProvider(this, {
context: edgelessToolbarThemeContext,
initialValue: ColorScheme.Light,
});
private readonly _toolbarProvider = new ContextProvider(this, {
context: edgelessToolbarContext,
initialValue: this,
});
activePopper: MenuPopper<HTMLElement> | null = null;
// calculate all the width manually
private get _availableWidth() {
return this.containerWidth - 2 * SAFE_AREA_WIDTH;
}
private get _cachedPresentHideToolbar() {
return !!this.std.get(EditPropsStore).getStorage('presentHideToolbar');
}
private get _denseQuickTools() {
return (
this._availableWidth -
this._seniorToolNavWidth -
1 * SENIOR_TOOL_WIDTH -
2 * TOOLBAR_PADDING_X <
this._quickToolsWidthTotal
);
}
private get _denseSeniorTools() {
return (
this._availableWidth -
this._quickToolsWidthTotal -
this._spaceWidthTotal <
this._seniorToolsWidthTotal
);
}
/**
* When enabled, the toolbar will auto-hide when the mouse is not over it.
*/
private get _enableAutoHide() {
return (
this.isPresentMode &&
this._cachedPresentHideToolbar &&
!this.presentSettingMenuShow &&
!this.presentFrameMenuShow
);
}
private get _hiddenQuickTools() {
return this._quickTools
.slice(this._visibleQuickToolSize)
.filter(tool => !!tool.menu);
}
private get _quickTools() {
const block = this.block;
if (!block) {
return [];
}
const quickTools = Array.from(
this.std.provider.getAll(QuickToolIdentifier).values()
);
const gfx = this.std.get(GfxControllerIdentifier);
return quickTools
.map(tool =>
tool({ block, gfx, toolbarContainer: this.toolbarContainer })
)
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
.filter(({ enable = true }) => enable);
}
private get _quickToolsWidthTotal() {
return (
this._quickTools.length * (QUICK_TOOL_SIZE + QUICK_TOOLS_GAP) -
QUICK_TOOLS_GAP
);
}
private get _seniorNextTooltip() {
if (this._seniorScrollNextDisabled) return '';
const nextTool =
this._seniorTools[this.scrollSeniorToolIndex + this.scrollSeniorToolSize];
return nextTool?.name ?? '';
}
private get _seniorPrevTooltip() {
if (this._seniorScrollPrevDisabled) return '';
const prevTool = this._seniorTools[this.scrollSeniorToolIndex - 1];
return prevTool?.name ?? '';
}
private get _seniorScrollNextDisabled() {
return (
this.scrollSeniorToolIndex + this.scrollSeniorToolSize >=
this._seniorTools.length
);
}
private get _seniorScrollPrevDisabled() {
return this.scrollSeniorToolIndex === 0;
}
private get _seniorToolNavWidth() {
return this._denseSeniorTools
? (SENIOR_TOOL_NAV_SIZE + DIVIDER_SPACE) * 2
: 0;
}
private get _seniorTools() {
const block = this.block;
if (!block) {
return [];
}
const seniorTools = Array.from(
this.std.provider.getAll(SeniorToolIdentifier).values()
);
const gfx = this.std.get(GfxControllerIdentifier);
return seniorTools
.map(tool =>
tool({ block, gfx, toolbarContainer: this.toolbarContainer })
)
.filter(({ enable = true }) => enable);
}
private get _seniorToolsWidthTotal() {
return (
this._seniorTools.length * (SENIOR_TOOL_WIDTH + SENIOR_TOOLS_GAP) -
SENIOR_TOOLS_GAP
);
}
private get _spaceWidthTotal() {
return DIVIDER_WIDTH + DIVIDER_SPACE * 2 + TOOLBAR_PADDING_X * 2;
}
private get _visibleQuickToolSize() {
if (!this._denseQuickTools) return this._quickTools.length;
const availableWidth =
this._availableWidth -
this._seniorToolNavWidth -
this._spaceWidthTotal -
SENIOR_TOOL_WIDTH;
return Math.max(
1,
Math.floor(
(availableWidth - QUICK_TOOL_MORE_SIZE - DIVIDER_SPACE) /
(QUICK_TOOL_SIZE + QUICK_TOOLS_GAP)
)
);
}
get edgelessTool() {
return this.gfx.tool.currentToolName$.value;
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get isPresentMode() {
return this.edgelessTool === 'frameNavigator';
}
get scrollSeniorToolSize() {
if (this._denseQuickTools) return 1;
const seniorAvailableWidth =
this._availableWidth - this._quickToolsWidthTotal - this._spaceWidthTotal;
if (seniorAvailableWidth >= this._seniorToolsWidthTotal)
return this._seniorTools.length;
return (
Math.floor(
(seniorAvailableWidth - (SENIOR_TOOL_NAV_SIZE + DIVIDER_SPACE) * 2) /
SENIOR_TOOL_WIDTH
) || 1
);
}
get slots() {
return this._slotsProvider.value;
}
constructor() {
super();
}
private _onSeniorNavNext() {
if (this._seniorScrollNextDisabled) return;
this.scrollSeniorToolIndex = Math.min(
this._seniorTools.length - this.scrollSeniorToolSize,
this.scrollSeniorToolIndex + this.scrollSeniorToolSize
);
}
private _onSeniorNavPrev() {
if (this._seniorScrollPrevDisabled) return;
this.scrollSeniorToolIndex = Math.max(
0,
this.scrollSeniorToolIndex - this.scrollSeniorToolSize
);
}
private _openMoreQuickToolsMenu(e: { currentTarget: HTMLElement }) {
if (!this._hiddenQuickTools.length) return;
this._moreQuickToolsMenuRef = e.currentTarget;
this._moreQuickToolsMenu = popMenu(
popupTargetFromElement(e.currentTarget as HTMLElement),
{
middleware: [
autoPlacement({
allowedPlacements: ['top'],
}),
offset({
mainAxis: (TOOLBAR_HEIGHT - QUICK_TOOL_MORE_SIZE) / 2 + 8,
}),
],
options: {
onClose: () => {
this._moreQuickToolsMenu = null;
this._moreQuickToolsMenuRef = null;
},
items: this._hiddenQuickTools.map(tool => tool.menu!),
},
}
);
}
private _renderContent() {
return html`
<div class="quick-tools">
${this._quickTools
.slice(0, this._visibleQuickToolSize)
.map(
tool => html`<div class="quick-tool-item">${tool.content}</div>`
)}
</div>
<div class="quick-tool-more">
<icon-button
?disabled=${!this._denseQuickTools}
.size=${20}
class="quick-tool-more-button"
@click=${this._openMoreQuickToolsMenu}
?active=${this._quickTools
.slice(this._visibleQuickToolSize)
.some(tool => tool.type === this.edgelessTool)}
>
${MoreHorizontalIcon({ width: '20px', height: '20px' })}
<affine-tooltip tip-position="top" .offset=${25}>
More Tools
</affine-tooltip>
</icon-button>
</div>
<div class="full-divider"></div>
<div class="senior-nav-button-wrapper prev">
<icon-button
.size=${20}
class="senior-nav-button"
?disabled=${this._seniorScrollPrevDisabled}
@click=${this._onSeniorNavPrev}
>
${ArrowLeftSmallIcon({ width: '20px', height: '20px' })}
${cache(
this._seniorPrevTooltip
? html` <affine-tooltip tip-position="top" .offset=${4}>
${this._seniorPrevTooltip}
</affine-tooltip>`
: nothing
)}
</icon-button>
</div>
<div class="senior-tools">
${this._seniorTools
.slice(
this.scrollSeniorToolIndex,
this.scrollSeniorToolIndex + this.scrollSeniorToolSize
)
.map(
tool => html`<div class="senior-tool-item">${tool.content}</div>`
)}
</div>
<div class="senior-nav-button-wrapper next">
<icon-button
.size=${20}
class="senior-nav-button"
?disabled=${this._seniorScrollNextDisabled}
@click=${this._onSeniorNavNext}
>
${ArrowRightSmallIcon({ width: '20px', height: '20px' })}
${cache(
this._seniorNextTooltip
? html` <affine-tooltip tip-position="top" .offset=${4}>
${this._seniorNextTooltip}
</affine-tooltip>`
: nothing
)}
</icon-button>
</div>
`;
}
override connectedCallback() {
super.connectedCallback();
this._toolbarProvider.setValue(this);
this._resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
const { width } = entry.contentRect;
this._onContainerResize({ w: width });
}
});
this._resizeObserver.observe(this);
this.disposables.add(
this.std
.get(ThemeProvider)
.theme$.subscribe(mode => this._themeProvider.setValue(mode))
);
if (!this.block) {
return;
}
this._disposables.add(
this.block.bindHotKey(
{
Escape: () => {
if (this.gfx.selection.editing) return;
if (this.edgelessTool === 'frameNavigator') return;
if (this.edgelessTool === 'default') {
if (this.activePopper) {
this.activePopper.dispose();
this.activePopper = null;
}
return;
}
this.gfx.tool.setTool(DefaultTool);
},
},
{ global: true }
)
);
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
}
override firstUpdated() {
const { _disposables, block, gfx } = this;
if (!block) return;
const slots = this.std.get(EdgelessLegacySlotIdentifier);
const editPropsStore = this.std.get(EditPropsStore);
_disposables.add(
gfx.viewport.viewportUpdated.subscribe(() => this.requestUpdate())
);
_disposables.add(
slots.readonlyUpdated.subscribe(() => {
this.requestUpdate();
})
);
_disposables.add(
slots.toolbarLocked.subscribe(disabled => {
this.toggleAttribute('disabled', disabled);
})
);
// This state from `editPropsStore` is not reactive,
// if the value is updated outside of this component, it will not be reflected.
_disposables.add(
editPropsStore.slots.storageUpdated.subscribe(({ key }) => {
if (key === 'presentHideToolbar') {
this.requestUpdate();
}
})
);
}
override render() {
const type = this.edgelessTool;
if (this.store.readonly && type !== 'frameNavigator') {
return nothing;
}
return html`
<div
class="edgeless-toolbar-wrapper"
data-app-theme=${this._appTheme$.value}
>
<div
class="edgeless-toolbar-toggle-control"
data-enable=${this._enableAutoHide}
>
<smooth-corner
class="edgeless-toolbar-smooth-corner"
.borderRadius=${16}
.smooth=${0.7}
.borderWidth=${1}
.bgColor=${'var(--affine-background-overlay-panel-color)'}
.borderColor=${'var(--affine-border-color)'}
style="filter: drop-shadow(${cssVar('toolbarShadow')})"
>
<div
class="edgeless-toolbar-container"
data-dense-quick=${
this._denseQuickTools && this._hiddenQuickTools.length > 0
}
data-dense-senior=${this._denseSeniorTools}
@dblclick=${stopPropagation}
@mousedown=${stopPropagation}
@pointerdown=${stopPropagation}
>
${
this.isPresentMode
? html`<presentation-toolbar
.edgeless=${this.block}
.settingMenuShow=${this.presentSettingMenuShow}
.frameMenuShow=${this.presentFrameMenuShow}
.setSettingMenuShow=${(show: boolean) =>
(this.presentSettingMenuShow = show)}
.setFrameMenuShow=${(show: boolean) =>
(this.presentFrameMenuShow = show)}
.containerWidth=${this.containerWidth}
></presentation-toolbar>`
: nothing
}
${this.isPresentMode ? nothing : this._renderContent()}
</div>
</smooth-corner>
</div>
</div>
`;
}
@state()
accessor presentFrameMenuShow = false;
@state()
accessor presentSettingMenuShow = false;
@state()
accessor scrollSeniorToolIndex = 0;
@query('.edgeless-toolbar-container')
accessor toolbarContainer!: HTMLElement;
}
export const edgelessToolbarWidget = WidgetViewExtension(
'affine:page',
EDGELESS_TOOLBAR_WIDGET,
literal`${unsafeStatic(EDGELESS_TOOLBAR_WIDGET)}`
);

View File

@@ -0,0 +1,35 @@
import { EdgelessToolIconButton } from './button/tool-icon-button';
import { EdgelessToolbarButton } from './button/toolbar-button';
import {
EDGELESS_TOOLBAR_WIDGET,
EdgelessToolbarWidget,
} from './edgeless-toolbar';
import { EdgelessSlideMenu } from './menu/slide-menu';
import { ToolbarArrowUpIcon } from './menu/toolbar-arrow-up-icon';
import { EdgelessFontFamilyPanel } from './panel/font-family-panel';
import { EdgelessFontWeightAndStylePanel } from './panel/font-weight-and-style-panel';
export function effects() {
customElements.define(EDGELESS_TOOLBAR_WIDGET, EdgelessToolbarWidget);
customElements.define('edgeless-toolbar-button', EdgelessToolbarButton);
customElements.define('edgeless-tool-icon-button', EdgelessToolIconButton);
customElements.define(
'edgeless-font-weight-and-style-panel',
EdgelessFontWeightAndStylePanel
);
customElements.define('edgeless-font-family-panel', EdgelessFontFamilyPanel);
customElements.define('edgeless-slide-menu', EdgelessSlideMenu);
customElements.define('toolbar-arrow-up-icon', ToolbarArrowUpIcon);
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-tool-icon-button': EdgelessToolIconButton;
'edgeless-toolbar-button': EdgelessToolbarButton;
'edgeless-toolbar-widget': EdgelessToolbarWidget;
'edgeless-font-weight-and-style-panel': EdgelessFontWeightAndStylePanel;
'edgeless-font-family-panel': EdgelessFontFamilyPanel;
'edgeless-slide-menu': EdgelessSlideMenu;
'toolbar-arrow-up-icon': ToolbarArrowUpIcon;
}
}

View File

@@ -0,0 +1,61 @@
import type { MenuConfig } from '@blocksuite/affine-components/context-menu';
import { createIdentifier } from '@blocksuite/global/di';
import type { BlockComponent } from '@blocksuite/std';
import type { GfxController } from '@blocksuite/std/gfx';
import type { ExtensionType } from '@blocksuite/store';
import { type TemplateResult } from 'lit';
export interface QuickTool {
type?: string;
enable?: boolean;
content: TemplateResult;
/**
* if not configured, the tool will not be shown in dense mode
*/
menu?: MenuConfig;
priority?: number;
}
export interface SeniorTool {
/**
* Used to show in nav-button's tooltip
*/
name: string;
content: TemplateResult;
enable?: boolean;
}
export type ToolBuilder<T> = (options: {
block: BlockComponent;
gfx: GfxController;
toolbarContainer: HTMLElement;
}) => T;
export const QuickToolIdentifier = createIdentifier<ToolBuilder<QuickTool>>(
'edgeless-quick-tool'
);
export const SeniorToolIdentifier = createIdentifier<ToolBuilder<SeniorTool>>(
'edgeless-senior-tool'
);
export const QuickToolExtension = (
id: string,
builder: ToolBuilder<QuickTool>
): ExtensionType => {
return {
setup: di => {
di.addImpl(QuickToolIdentifier(id), () => builder);
},
};
};
export const SeniorToolExtension = (
id: string,
builder: ToolBuilder<SeniorTool>
): ExtensionType => {
return {
setup: di => {
di.addImpl(SeniorToolIdentifier(id), () => builder);
},
};
};

View File

@@ -0,0 +1,7 @@
export * from './config';
export * from './context';
export * from './create-popper';
export * from './draggable';
export * from './edgeless-toolbar';
export * from './extension';
export * from './mixins';

View File

@@ -0,0 +1,184 @@
import { WithDisposable } from '@blocksuite/global/lit';
import { ArrowRightSmallIcon } from '@blocksuite/icons/lit';
import { consume } from '@lit/context';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import {
type EdgelessToolbarSlots,
edgelessToolbarSlotsContext,
} from '../index';
export class EdgelessSlideMenu extends WithDisposable(LitElement) {
static override styles = css`
:host {
max-width: 100%;
}
::-webkit-scrollbar {
display: none;
}
.slide-menu-wrapper {
position: relative;
}
.menu-container {
background: var(--affine-background-overlay-panel-color);
border-radius: 8px 8px 0 0;
border: 1px solid var(--affine-border-color);
border-bottom: none;
display: flex;
align-items: center;
width: fit-content;
max-width: 100%;
position: relative;
height: calc(var(--menu-height) + 1px);
box-sizing: border-box;
padding-left: 10px;
scroll-snap-type: x mandatory;
}
.menu-container-scrollable {
overflow-x: auto;
overscroll-behavior: none;
scrollbar-width: none;
height: 100%;
padding-right: 10px;
}
.slide-menu-content {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
transition: left 0.5s ease-in-out;
width: fit-content;
}
.next-slide-button,
.previous-slide-button {
align-items: center;
justify-content: center;
position: absolute;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--affine-border-color);
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
color: var(--affine-icon-color);
transition:
transform 0.3s ease-in-out,
opacity 0.5s ease-in-out;
z-index: 12;
}
.next-slide-button {
opacity: 0;
display: flex;
top: 50%;
right: 0;
transform: translate(50%, -50%) scale(0.5);
}
.next-slide-button:hover {
cursor: pointer;
transform: translate(50%, -50%) scale(1);
}
.previous-slide-button {
opacity: 0;
top: 50%;
left: 0;
transform: translate(-50%, -50%) scale(0.5);
}
.previous-slide-button:hover {
cursor: pointer;
transform: translate(-50%, -50%) scale(1);
}
.previous-slide-button svg {
transform: rotate(180deg);
}
`;
private _handleSlideButtonClick(direction: 'left' | 'right') {
const totalWidth = this._slideMenuContent.clientWidth;
const currentScrollLeft = this._menuContainer.scrollLeft;
const menuWidth = this._menuContainer.clientWidth;
const newLeft =
currentScrollLeft + (direction === 'left' ? -menuWidth : menuWidth);
this._menuContainer.scrollTo({
left: Math.max(0, Math.min(newLeft, totalWidth)),
behavior: 'smooth',
});
}
private _handleWheel(event: WheelEvent) {
event.stopPropagation();
}
private _toggleSlideButton() {
const scrollLeft = this._menuContainer.scrollLeft;
const menuWidth = this._menuContainer.clientWidth;
const leftMin = 0;
const leftMax = this._slideMenuContent.clientWidth - menuWidth;
this.showPrevious = scrollLeft > leftMin;
this.showNext = scrollLeft < leftMax;
}
override firstUpdated() {
setTimeout(this._toggleSlideButton.bind(this), 0);
this._disposables.addFromEvent(this._menuContainer, 'scrollend', () => {
this._toggleSlideButton();
});
this._disposables.add(
this.toolbarSlots.resize.subscribe(() => this._toggleSlideButton())
);
}
override render() {
const iconSize = { width: '32px', height: '32px' };
return html`
<div class="slide-menu-wrapper">
<div
class="previous-slide-button"
@click=${() => this._handleSlideButtonClick('left')}
style=${styleMap({ opacity: this.showPrevious ? '1' : '0' })}
>
${ArrowRightSmallIcon(iconSize)}
</div>
<div
class="menu-container"
style=${styleMap({ '--menu-height': this.height })}
>
<slot name="prefix"></slot>
<div class="menu-container-scrollable">
<div class="slide-menu-content" @wheel=${this._handleWheel}>
<slot></slot>
</div>
</div>
</div>
<div
style=${styleMap({ opacity: this.showNext ? '1' : '0' })}
class="next-slide-button"
@click=${() => this._handleSlideButtonClick('right')}
>
${ArrowRightSmallIcon(iconSize)}
</div>
</div>
`;
}
@query('.menu-container-scrollable')
private accessor _menuContainer!: HTMLDivElement;
@query('.slide-menu-content')
private accessor _slideMenuContent!: HTMLDivElement;
@property({ attribute: false })
accessor height = '40px';
@property({ attribute: false })
accessor showNext = false;
@property({ attribute: false })
accessor showPrevious = false;
@consume({ context: edgelessToolbarSlotsContext })
accessor toolbarSlots!: EdgelessToolbarSlots;
}

View File

@@ -0,0 +1,17 @@
import { ArrowUpSmallIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { css, html } from 'lit';
export class ToolbarArrowUpIcon extends ShadowlessElement {
static override styles = css`
.arrow-up-icon {
position: absolute;
top: -2px;
right: -2px;
}
`;
override render() {
return html`<span class="arrow-up-icon"> ${ArrowUpSmallIcon()} </span>`;
}
}

View File

@@ -0,0 +1,3 @@
export * from './quick-tool.mixin';
export * from './tool.mixin';
export * from './toolbar-button-with-menu.mixin';

View File

@@ -0,0 +1,21 @@
import type { Constructor } from '@blocksuite/global/utils';
import type { LitElement } from 'lit';
import {
// oxlint-disable-next-line no-unused-vars
type EdgelessToolbarToolClass,
EdgelessToolbarToolMixin,
} from './tool.mixin.js';
export declare abstract class QuickToolMixinClass extends EdgelessToolbarToolClass {}
/**
* Mixin for quick tool item.
*/
export const QuickToolMixin = <T extends Constructor<LitElement>>(
SuperClass: T
) => {
abstract class DerivedClass extends EdgelessToolbarToolMixin(SuperClass) {}
return DerivedClass as unknown as T & Constructor<QuickToolMixinClass>;
};

View File

@@ -0,0 +1,171 @@
import type { ColorScheme } from '@blocksuite/affine-model';
import {
// oxlint-disable-next-line no-unused-vars
type DisposableClass,
WithDisposable,
} from '@blocksuite/global/lit';
import type { Constructor } from '@blocksuite/global/utils';
import type { BlockComponent } from '@blocksuite/std';
import {
type GfxController,
GfxControllerIdentifier,
type ToolController,
type ToolOptionWithType,
type ToolType,
} from '@blocksuite/std/gfx';
import { consume } from '@lit/context';
import { effect } from '@preact/signals-core';
import { cssVar } from '@toeverything/theme';
import type { LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import {
edgelessToolbarContext,
type EdgelessToolbarSlots,
edgelessToolbarSlotsContext,
edgelessToolbarThemeContext,
} from '../context';
import { createPopper, type MenuPopper } from '../create-popper';
import type { EdgelessToolbarWidget } from '../edgeless-toolbar';
export declare abstract class EdgelessToolbarToolClass extends DisposableClass {
active: boolean;
createPopper: typeof createPopper;
edgeless: BlockComponent;
edgelessTool: ToolOptionWithType;
enableActiveBackground?: boolean;
popper: MenuPopper<HTMLElement> | null;
setEdgelessTool: ToolController['setTool'];
gfx: GfxController;
theme: ColorScheme;
toolbarContainer: HTMLElement | null;
toolbarSlots: EdgelessToolbarSlots;
/**
* @return true if operation was successful
*/
tryDisposePopper: () => boolean;
abstract type: ToolType | ToolType[];
accessor toolbar: EdgelessToolbarWidget;
}
export const EdgelessToolbarToolMixin = <T extends Constructor<LitElement>>(
SuperClass: T
) => {
abstract class DerivedClass extends WithDisposable(SuperClass) {
enableActiveBackground = false;
abstract type: ToolType | ToolType[];
get active() {
const { type } = this;
const activeType = this.edgelessTool?.toolType;
return activeType
? Array.isArray(type)
? type.includes(activeType)
: activeType === type
: false;
}
get gfx() {
return this.edgeless.std.get(GfxControllerIdentifier);
}
get setEdgelessTool() {
return this.gfx.tool.setTool;
}
private _applyActiveStyle() {
if (!this.enableActiveBackground) return;
this.style.background = this.active
? cssVar('hoverColor')
: 'transparent';
}
private _updateActiveEdgelessTool() {
this.edgelessTool = this.gfx.tool.currentToolOption$.value;
this._applyActiveStyle();
}
override connectedCallback() {
super.connectedCallback();
if (!this.edgeless) return;
this._updateActiveEdgelessTool();
this._applyActiveStyle();
this._disposables.add(
effect(() => {
this._updateActiveEdgelessTool();
})
);
}
// TODO: move to toolbar-tool-with-menu.mixin
createPopper(...args: Parameters<typeof createPopper>) {
if (this.toolbar.activePopper) {
this.toolbar.activePopper.dispose();
this.toolbar.activePopper = null;
}
this.popper = createPopper(args[0], args[1], {
...args[2],
onDispose: () => {
args[2]?.onDispose?.();
this.popper = null;
},
}) as MenuPopper<HTMLElement>;
this.toolbar.activePopper = this.popper;
return this.popper;
}
override disconnectedCallback() {
super.disconnectedCallback();
this.popper?.dispose();
}
tryDisposePopper() {
if (!this.active) return false;
if (this.popper) {
this.popper.dispose();
this.popper = null;
return true;
}
return false;
}
@property({ attribute: false })
accessor edgeless!: BlockComponent;
@state()
accessor edgelessTool!: ToolOptionWithType | null;
@state()
public accessor popper: MenuPopper<HTMLElement> | null = null;
@consume({ context: edgelessToolbarThemeContext, subscribe: true })
accessor theme!: ColorScheme;
@consume({ context: edgelessToolbarContext })
accessor toolbar!: EdgelessToolbarWidget;
@property({ attribute: false })
accessor toolbarContainer: HTMLElement | null = null;
@consume({ context: edgelessToolbarSlotsContext })
accessor toolbarSlots!: EdgelessToolbarSlots;
}
return DerivedClass as unknown as T & Constructor<EdgelessToolbarToolClass>;
};

View File

@@ -0,0 +1,20 @@
import type { Constructor } from '@blocksuite/global/utils';
import type { LitElement } from 'lit';
import {
// oxlint-disable-next-line no-unused-vars
type EdgelessToolbarToolClass,
EdgelessToolbarToolMixin,
} from './tool.mixin.js';
export declare abstract class ToolbarButtonWithMenuClass extends EdgelessToolbarToolClass {}
export const ToolbarButtonWithMenuMixin = <
T extends Constructor<LitElement> = Constructor<LitElement>,
>(
SuperClass: T
) => {
abstract class DerivedClass extends EdgelessToolbarToolMixin(SuperClass) {}
return DerivedClass as unknown as T & Constructor<ToolbarButtonWithMenuClass>;
};

View File

@@ -0,0 +1,57 @@
import { TextUtils } from '@blocksuite/affine-block-surface';
import { FontFamily, FontFamilyList } from '@blocksuite/affine-model';
import { DoneIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
export class EdgelessFontFamilyPanel extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: start;
flex-direction: column;
min-width: 136px;
}
edgeless-tool-icon-button {
width: 100%;
}
`;
private _onSelect(value: FontFamily) {
this.value = value;
if (this.onSelect) {
this.onSelect(value);
}
}
override render() {
return repeat(
FontFamilyList,
item => item[0],
([font, name]) => {
const active = this.value === font;
return html`
<edgeless-tool-icon-button
data-font="${name}"
style="font-family: ${TextUtils.wrapFontFamily(font)}"
.iconContainerPadding=${[4, 8]}
.justify=${'space-between'}
.active=${active}
.iconSize=${'20px'}
@click=${() => this._onSelect(font)}
>
${name} ${active ? DoneIcon() : nothing}
</edgeless-tool-icon-button>
`;
}
);
}
@property({ attribute: false })
accessor onSelect: ((value: FontFamily) => void) | undefined = undefined;
@property({ attribute: false })
accessor value: FontFamily = FontFamily.Inter;
}

View File

@@ -0,0 +1,163 @@
import { TextUtils } from '@blocksuite/affine-block-surface';
import {
FontFamily,
FontFamilyMap,
FontStyle,
FontWeight,
} from '@blocksuite/affine-model';
import { DoneIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
const FONT_WEIGHT_CHOOSE: [FontWeight, () => string][] = [
[FontWeight.Light, () => 'Light'],
[FontWeight.Regular, () => 'Regular'],
[FontWeight.SemiBold, () => 'Semibold'],
];
export class EdgelessFontWeightAndStylePanel extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: start;
flex-direction: column;
min-width: 124px;
}
edgeless-tool-icon-button {
width: 100%;
}
`;
private _isActive(
fontWeight: FontWeight,
fontStyle: FontStyle = FontStyle.Normal
) {
return this.fontWeight === fontWeight && this.fontStyle === fontStyle;
}
private _isDisabled(
fontWeight: FontWeight,
fontStyle: FontStyle = FontStyle.Normal
) {
// Compatible with old data
if (!(this.fontFamily in FontFamilyMap)) return false;
const fontFace = TextUtils.getFontFaces()
.filter(TextUtils.isSameFontFamily(this.fontFamily))
.find(
fontFace =>
fontFace.weight === fontWeight && fontFace.style === fontStyle
);
return !fontFace;
}
private _onSelect(
fontWeight: FontWeight,
fontStyle: FontStyle = FontStyle.Normal
) {
this.fontWeight = fontWeight;
this.fontStyle = fontStyle;
if (this.onSelect) {
this.onSelect(fontWeight, fontStyle);
}
}
override render() {
let fontFaces = TextUtils.getFontFacesByFontFamily(this.fontFamily);
// Compatible with old data
if (fontFaces.length === 0) {
fontFaces = TextUtils.getFontFacesByFontFamily(FontFamily.Inter);
}
const fontFacesWithNormal = fontFaces.filter(
fontFace => fontFace.style === FontStyle.Normal
);
const fontFacesWithItalic = fontFaces.filter(
fontFace => fontFace.style === FontStyle.Italic
);
return join(
[
fontFacesWithNormal.length > 0
? repeat(
fontFacesWithNormal,
fontFace => fontFace.weight,
fontFace => {
const active = this._isActive(fontFace.weight as FontWeight);
return html`
<edgeless-tool-icon-button
data-weight="${fontFace.weight}"
.iconContainerPadding=${[4, 8]}
.justify=${'space-between'}
.disabled=${this._isDisabled(fontFace.weight as FontWeight)}
.active=${active}
.iconSize=${'20px'}
@click=${() =>
this._onSelect(fontFace.weight as FontWeight)}
>
${choose(fontFace.weight, FONT_WEIGHT_CHOOSE)}
${active ? DoneIcon() : nothing}
</edgeless-tool-icon-button>
`;
}
)
: nothing,
fontFacesWithItalic.length > 0
? repeat(
fontFacesWithItalic,
fontFace => fontFace.weight,
fontFace => {
const active = this._isActive(
fontFace.weight as FontWeight,
FontStyle.Italic
);
return html`
<edgeless-tool-icon-button
data-weight="${fontFace.weight} italic"
.iconContainerPadding=${[4, 8]}
.justify=${'space-between'}
.disabled=${this._isDisabled(
fontFace.weight as FontWeight,
FontStyle.Italic
)}
.active=${active}
@click=${() =>
this._onSelect(
fontFace.weight as FontWeight,
FontStyle.Italic
)}
>
${choose(fontFace.weight, FONT_WEIGHT_CHOOSE)} Italic
${active ? DoneIcon() : nothing}
</edgeless-tool-icon-button>
`;
}
)
: nothing,
].filter(item => item !== nothing),
() => html`
<edgeless-menu-divider
data-orientation="horizontal"
></edgeless-menu-divider>
`
);
}
@property({ attribute: false })
accessor fontFamily = FontFamily.Inter;
@property({ attribute: false })
accessor fontStyle = FontStyle.Normal;
@property({ attribute: false })
accessor fontWeight = FontWeight.Regular;
@property({ attribute: false })
accessor onSelect:
| ((fontWeight: FontWeight, fontStyle: FontStyle) => void)
| undefined;
}

View File

@@ -0,0 +1,23 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { edgelessToolbarWidget } from './edgeless-toolbar';
import { effects } from './effects';
export class EdgelessToolbarViewExtension extends ViewExtensionProvider {
override name = 'affine-edgeless-toolbar-widget';
override effect() {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
if (this.isEdgeless(context.scope)) {
context.register(edgelessToolbarWidget);
}
}
}

View File

@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/surface" },
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" }
]
}

View File

@@ -0,0 +1,39 @@
{
"name": "@blocksuite/affine-widget-edgeless-zoom-toolbar",
"description": "Affine edgeless zoom toolbar widget.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.23"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,17 @@
import {
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
AffineEdgelessZoomToolbarWidget,
} from '.';
import { MobileZoomRuler } from './mobile-zoom-ruler';
import { ZoomBarToggleButton } from './zoom-bar-toggle-button';
import { EdgelessZoomToolbar } from './zoom-toolbar';
export function effects() {
customElements.define('edgeless-zoom-toolbar', EdgelessZoomToolbar);
customElements.define('zoom-bar-toggle-button', ZoomBarToggleButton);
customElements.define('mobile-zoom-ruler', MobileZoomRuler);
customElements.define(
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
AffineEdgelessZoomToolbarWidget
);
}

View File

@@ -0,0 +1,104 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import type { RootBlockModel } from '@blocksuite/affine-model';
import { IS_MOBILE } from '@blocksuite/global/env';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { state } from 'lit/decorators.js';
import { literal, unsafeStatic } from 'lit/static-html.js';
export const AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET =
'affine-edgeless-zoom-toolbar-widget';
export class AffineEdgelessZoomToolbarWidget extends WidgetComponent<RootBlockModel> {
static override styles = css`
:host {
position: absolute;
bottom: var(--affine-edgeless-zoom-toolbar-bottom, 20px);
left: 12px;
z-index: var(--affine-z-index-popover);
display: flex;
justify-content: center;
pointer-events: none;
-webkit-user-select: none;
user-select: none;
}
mobile-zoom-ruler {
pointer-events: auto;
}
@container viewport (width <= 1200px) {
edgeless-zoom-toolbar {
display: none;
}
}
@container viewport (width > 1200px) {
zoom-bar-toggle-button {
display: none;
}
}
`;
get edgeless() {
return this.block;
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
override connectedCallback() {
super.connectedCallback();
this.disposables.add(
effect(() => {
const currentTool = this.gfx.tool.currentToolName$.value;
if (currentTool !== 'frameNavigator') {
this._hide = false;
}
this.requestUpdate();
})
);
}
override firstUpdated() {
const { disposables, std } = this;
const slots = std.get(EdgelessLegacySlotIdentifier);
disposables.add(
slots.navigatorSettingUpdated.subscribe(({ hideToolbar }) => {
if (hideToolbar !== undefined) {
this._hide = hideToolbar;
}
})
);
}
override render() {
if (this._hide) {
return nothing;
}
if (IS_MOBILE) {
return html`<mobile-zoom-ruler .std=${this.std}></mobile-zoom-ruler>`;
}
return html`
<edgeless-zoom-toolbar .std=${this.std}></edgeless-zoom-toolbar>
<zoom-bar-toggle-button .std=${this.std}></zoom-bar-toggle-button>
`;
}
@state()
private accessor _hide = false;
}
export const edgelessZoomToolbarWidget = WidgetViewExtension(
'affine:page',
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
literal`${unsafeStatic(AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET)}`
);

View File

@@ -0,0 +1,139 @@
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { ViewBarIcon } from '@blocksuite/icons/lit';
import type { BlockStdScope } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
/**
* Compact zoom indicator for narrow / mobile edgeless viewports.
* Shows the live zoom percentage and a fit-to-screen action in a pill HUD
* anchored to the bottom-left of the canvas.
*/
export class MobileZoomRuler extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
pointer-events: auto;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.zoom-pill {
display: flex;
align-items: center;
height: 32px;
background: var(--affine-background-overlay-panel-color);
border: 1px solid var(--affine-border-color);
border-radius: 999px;
box-shadow: var(--affine-shadow-1);
overflow: hidden;
}
.zoom-label {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
padding: 0 12px;
font-size: 12px;
font-weight: 500;
line-height: 1;
color: var(--affine-text-secondary-color);
white-space: nowrap;
user-select: none;
}
.divider {
width: 1px;
height: 16px;
background: var(--affine-border-color);
flex-shrink: 0;
}
.fit-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 100%;
padding: 0;
border: none;
background: transparent;
color: var(--affine-icon-color);
cursor: pointer;
}
.fit-button:hover:not(:disabled) {
background: var(--affine-hover-color);
color: var(--affine-primary-color);
}
.fit-button:disabled {
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
.fit-button svg {
width: 20px;
height: 20px;
}
`;
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get viewport() {
return this.gfx.viewport;
}
get zoom() {
if (!this.viewport) {
return 1;
}
return this.viewport.zoom;
}
override firstUpdated() {
const { disposables } = this;
const viewport = this.viewport;
if (!viewport) {
return;
}
disposables.add(
viewport.viewportUpdated.subscribe(() => this.requestUpdate())
);
disposables.add(viewport.zoomUpdated.subscribe(() => this.requestUpdate()));
}
override render() {
const formattedZoom = `${Math.round(this.zoom * 100)}%`;
const locked = this.viewport?.locked || this.std.store.readonly;
return html`
<div
class="zoom-pill"
@dblclick=${stopPropagation}
@mousedown=${stopPropagation}
@mouseup=${stopPropagation}
@pointerdown=${stopPropagation}
>
<span class="zoom-label">${formattedZoom}</span>
<span class="divider"></span>
<button
class="fit-button"
aria-label="Fit to screen"
?disabled=${locked}
@click=${() => this.gfx.fitToScreen()}
>
${ViewBarIcon()}
</button>
</div>
`;
}
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,23 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
import { edgelessZoomToolbarWidget } from './index';
export class EdgelessZoomToolbarViewExtension extends ViewExtensionProvider {
override name = 'affine-edgeless-zoom-toolbar-widget';
override effect() {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
if (this.isEdgeless(context.scope)) {
context.register(edgelessZoomToolbarWidget);
}
}
}

View File

@@ -0,0 +1,113 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import { createLitPortal } from '@blocksuite/affine-components/portal';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
import type { BlockStdScope } from '@blocksuite/std';
import { offset } from '@floating-ui/dom';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
export class ZoomBarToggleButton extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
}
.toggle-button {
display: flex;
position: relative;
}
edgeless-zoom-toolbar {
position: absolute;
bottom: initial;
}
`;
private _abortController: AbortController | null = null;
private _closeZoomMenu() {
if (this._abortController && !this._abortController.signal.aborted) {
this._abortController.abort();
this._abortController = null;
this._showPopper = false;
}
}
private get _slots() {
return this.std.get(EdgelessLegacySlotIdentifier);
}
private _toggleZoomMenu() {
if (this._abortController && !this._abortController.signal.aborted) {
this._closeZoomMenu();
return;
}
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this._showPopper = false;
});
createLitPortal({
template: html`<edgeless-zoom-toolbar
.std=${this.std}
.layout=${'vertical'}
></edgeless-zoom-toolbar>`,
container: this._toggleButton,
computePosition: {
referenceElement: this._toggleButton,
placement: 'top',
middleware: [offset(4)],
autoUpdate: true,
},
abortController: this._abortController,
closeOnClickAway: true,
});
this._showPopper = true;
}
override disconnectedCallback() {
super.disconnectedCallback();
this._closeZoomMenu();
}
override firstUpdated() {
const { disposables } = this;
disposables.add(
this._slots.readonlyUpdated.subscribe(() => {
this.requestUpdate();
})
);
}
override render() {
if (this.std.store.readonly) {
return nothing;
}
return html`
<div class="toggle-button" @pointerdown=${stopPropagation}>
<edgeless-tool-icon-button
.tooltip=${'Toggle Zoom Tool Bar'}
.tipPosition=${'right'}
.active=${this._showPopper}
.arrow=${false}
.activeMode=${'background'}
.iconContainerPadding=${6}
.iconSize=${'24px'}
@click=${() => this._toggleZoomMenu()}
>
${MoreHorizontalIcon()}
</edgeless-tool-icon-button>
</div>
`;
}
@state()
private accessor _showPopper = false;
@query('.toggle-button')
private accessor _toggleButton!: HTMLElement;
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,213 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { MinusIcon, PlusIcon, ViewBarIcon } from '@blocksuite/icons/lit';
import type { BlockStdScope } from '@blocksuite/std';
import {
GfxControllerIdentifier,
ZOOM_MAX,
ZOOM_MIN,
ZOOM_STEP,
} from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import clamp from 'lodash-es/clamp';
export class EdgelessZoomToolbar extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
}
.edgeless-zoom-toolbar-container {
display: flex;
align-items: center;
background: transparent;
border-radius: 8px;
fill: currentcolor;
padding: 4px;
}
.edgeless-zoom-toolbar-container.horizantal {
flex-direction: row;
}
.edgeless-zoom-toolbar-container.vertical {
flex-direction: column;
width: 40px;
background-color: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
border: 1px solid var(--affine-border-color);
border-radius: 8px;
}
.edgeless-zoom-toolbar-container[level='second'] {
position: absolute;
bottom: 8px;
transform: translateY(-100%);
}
.edgeless-zoom-toolbar-container[hidden] {
display: none;
}
.zoom-percent {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 32px;
border: none;
box-sizing: border-box;
padding: 4px;
color: var(--affine-icon-color);
background-color: transparent;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
font-size: 12px;
font-weight: 500;
text-align: center;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.zoom-percent:hover {
color: var(--affine-primary-color);
background-color: var(--affine-hover-color);
}
.zoom-percent[disabled] {
pointer-events: none;
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
`;
get slots() {
return this.std.get(EdgelessLegacySlotIdentifier);
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get edgelessTool() {
return this.gfx.tool.currentToolOption$.peek();
}
get locked() {
return this.viewport.locked;
}
get viewport() {
return this.gfx.viewport;
}
setZoomByStep = (step: number) => {
this.viewport.smoothZoom(clamp(this.zoom + step, ZOOM_MIN, ZOOM_MAX));
};
get zoom() {
if (!this.viewport) {
console.error('Something went wrong, viewport is not available');
return 1;
}
return this.viewport.zoom;
}
private _isVerticalBar() {
return this.layout === 'vertical';
}
override connectedCallback() {
super.connectedCallback();
this.disposables.add(
effect(() => {
void this.gfx.tool.currentToolName$.value;
this.requestUpdate();
})
);
}
override firstUpdated() {
const { disposables } = this;
disposables.add(
this.viewport.viewportUpdated.subscribe(() => this.requestUpdate())
);
disposables.add(
this.slots.readonlyUpdated.subscribe(() => {
this.requestUpdate();
})
);
}
override render() {
if (this.std.store.readonly) {
return nothing;
}
const formattedZoom = `${Math.round(this.zoom * 100)}%`;
const classes = `edgeless-zoom-toolbar-container ${this.layout}`;
const locked = this.locked;
return html`
<div
class=${classes}
@dblclick=${stopPropagation}
@mousedown=${stopPropagation}
@mouseup=${stopPropagation}
@pointerdown=${stopPropagation}
>
<edgeless-tool-icon-button
.tooltip=${'Fit to screen'}
.tipPosition=${this._isVerticalBar() ? 'right' : 'top-end'}
.arrow=${!this._isVerticalBar()}
@click=${() => this.gfx.fitToScreen()}
.iconContainerPadding=${4}
.iconSize=${'24px'}
.disabled=${locked}
>
${ViewBarIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.tooltip=${'Zoom out'}
.tipPosition=${this._isVerticalBar() ? 'right' : 'top'}
.arrow=${!this._isVerticalBar()}
@click=${() => this.setZoomByStep(-ZOOM_STEP)}
.iconContainerPadding=${4}
.iconSize=${'24px'}
.disabled=${locked}
>
${MinusIcon()}
</edgeless-tool-icon-button>
<button
class="zoom-percent"
@click=${() => this.viewport.smoothZoom(1)}
.disabled=${locked}
>
${formattedZoom}
</button>
<edgeless-tool-icon-button
.tooltip=${'Zoom in'}
.tipPosition=${this._isVerticalBar() ? 'right' : 'top'}
.arrow=${!this._isVerticalBar()}
@click=${() => this.setZoomByStep(ZOOM_STEP)}
.iconContainerPadding=${4}
.iconSize=${'24px'}
.disabled=${locked}
>
${PlusIcon()}
</edgeless-tool-icon-button>
</div>
`;
}
@property({ attribute: false })
accessor layout: 'horizontal' | 'vertical' = 'horizontal';
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/surface" },
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" }
]
}

View File

@@ -0,0 +1,36 @@
{
"name": "@blocksuite/affine-widget-frame-title",
"description": "Affine frame title widget.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@lit/context": "^1.1.2",
"@toeverything/theme": "^1.1.23",
"lit": "^3.2.0"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,21 @@
import { type FrameBlockModel } from '@blocksuite/affine-model';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
import { html } from 'lit';
import { literal, unsafeStatic } from 'lit/static-html.js';
export const AFFINE_FRAME_TITLE_WIDGET = 'affine-frame-title-widget';
export class AffineFrameTitleWidget extends WidgetComponent<FrameBlockModel> {
override render() {
return html`<affine-frame-title
.model=${this.model}
data-id=${this.model.id}
></affine-frame-title>`;
}
}
export const frameTitleWidget = WidgetViewExtension(
'affine:frame',
AFFINE_FRAME_TITLE_WIDGET,
literal`${unsafeStatic(AFFINE_FRAME_TITLE_WIDGET)}`
);

View File

@@ -0,0 +1,185 @@
import { FrameBlockModel } from '@blocksuite/affine-model';
import type { RichText } from '@blocksuite/affine-rich-text';
import { Bound } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import { type BlockComponent, ShadowlessElement } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import {
AFFINE_FRAME_TITLE_WIDGET,
type AffineFrameTitleWidget,
} from './affine-frame-title-widget';
import type { AffineFrameTitle } from './frame-title';
import { frameTitleStyleVars } from './styles';
export class EdgelessFrameTitleEditor extends WithDisposable(
ShadowlessElement
) {
static override styles = css`
.frame-title-editor {
display: flex;
align-items: center;
transform-origin: top left;
border-radius: 4px;
width: fit-content;
padding: 0 4px;
outline: none;
z-index: 1;
border: 1px solid var(--affine-primary-color);
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
overflow: hidden;
font-family: var(--affine-font-family);
}
`;
get editorHost() {
return this.edgeless.host;
}
get inlineEditor() {
return this.richText?.inlineEditor;
}
get gfx() {
return this.edgeless.std.get(GfxControllerIdentifier);
}
get selection() {
return this.gfx.selection;
}
private _unmount() {
// dispose in advance to avoid execute `this.remove()` twice
this.disposables.dispose();
this.selection.set({
elements: [],
editing: false,
});
this.remove();
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override firstUpdated(): void {
const dispatcher = this.edgeless.std.event;
this.updateComplete
.then(() => {
if (!this.inlineEditor) return;
this.inlineEditor.selectAll();
this.inlineEditor.slots.renderComplete.subscribe(() => {
this.requestUpdate();
});
this.disposables.add(
dispatcher.add('keyDown', ctx => {
const state = ctx.get('keyboardState');
if (state.raw.key === 'Enter' && !state.raw.isComposing) {
this._unmount();
return true;
}
requestAnimationFrame(() => {
this.requestUpdate();
});
return false;
})
);
this.disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(() => {
this.requestUpdate();
})
);
this.disposables.add(dispatcher.add('click', () => true));
this.disposables.add(dispatcher.add('doubleClick', () => true));
if (!this.inlineEditor.rootElement) return;
this.disposables.addFromEvent(
this.inlineEditor.rootElement,
'blur',
() => {
this._unmount();
}
);
})
.catch(console.error);
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.richText?.updateComplete;
return result;
}
override render() {
const rootBlockId = this.editorHost.store.root?.id;
if (!rootBlockId) return nothing;
const viewport = this.gfx.viewport;
const bound = Bound.deserialize(this.frameModel.xywh);
const [x, y] = viewport.toViewCoord(bound.x, bound.y);
const isInner = this.gfx.grid.has(
this.frameModel.elementBound,
true,
true,
model => model !== this.frameModel && model instanceof FrameBlockModel
);
const frameTitleWidget = this.edgeless.std.view.getWidget(
AFFINE_FRAME_TITLE_WIDGET,
this.frameModel.id
) as AffineFrameTitleWidget | null;
if (!frameTitleWidget) return nothing;
const frameTitle =
frameTitleWidget.querySelector<AffineFrameTitle>('affine-frame-title');
const colors = frameTitle?.colors ?? {
background: cssVarV2('edgeless/frame/background/white'),
text: 'var(--affine-text-primary-color)',
};
const inlineEditorStyle = styleMap({
fontSize: frameTitleStyleVars.fontSize + 'px',
position: 'absolute',
left: (isInner ? x + 4 : x) + 'px',
top: (isInner ? y + 4 : y - (frameTitleStyleVars.height + 8 / 2)) + 'px',
minWidth: '8px',
height: frameTitleStyleVars.height + 'px',
background: colors.background,
color: colors.text,
});
const richTextStyle = styleMap({
height: 'fit-content',
});
return html`<div class="frame-title-editor" style=${inlineEditorStyle}>
<rich-text
.yText=${this.frameModel.props.title.yText}
.enableFormat=${false}
.enableAutoScrollHorizontally=${false}
style=${richTextStyle}
></rich-text>
</div>`;
}
@property({ attribute: false })
accessor edgeless!: BlockComponent;
@property({ attribute: false })
accessor frameModel!: FrameBlockModel;
@query('rich-text')
accessor richText: RichText | null = null;
}

View File

@@ -0,0 +1,15 @@
import {
AFFINE_FRAME_TITLE_WIDGET,
AffineFrameTitleWidget,
} from './affine-frame-title-widget.js';
import { EdgelessFrameTitleEditor } from './edgeless-frame-title-editor.js';
import { AFFINE_FRAME_TITLE, AffineFrameTitle } from './frame-title.js';
export function effects() {
customElements.define(AFFINE_FRAME_TITLE_WIDGET, AffineFrameTitleWidget);
customElements.define(AFFINE_FRAME_TITLE, AffineFrameTitle);
customElements.define(
'edgeless-frame-title-editor',
EdgelessFrameTitleEditor
);
}

View File

@@ -0,0 +1,293 @@
import { parseStringToRgba } from '@blocksuite/affine-components/color-picker';
import {
ColorScheme,
FrameBlockModel,
isTransparent,
} from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { on } from '@blocksuite/affine-shared/utils';
import { Bound, type SerializedXYWH } from '@blocksuite/global/gfx';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
type BlockStdScope,
PropTypes,
requiredProperties,
stdContext,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { consume } from '@lit/context';
import { themeToVar } from '@toeverything/theme/v2';
import { LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import { mountFrameTitleEditor } from './mount-frame-title-editor.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.props.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.store;
}
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)`,
];
this.style.display = '';
this.style.setProperty('--bg-color', this.colors.background);
this.style.left = '0px';
this.style.top = '0px';
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.subscribe(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.subscribe(() => {
this._xywh = this.model.xywh;
this.requestUpdate();
})
);
_disposables.add(
gfx.selection.slots.updated.subscribe(() => {
this._editing =
gfx.selection.selectedIds[0] === this.model.id &&
gfx.selection.editing;
})
);
_disposables.add(
gfx.viewport.viewportUpdated.subscribe(({ zoom }) => {
this._zoom = zoom;
this.requestUpdate();
})
);
_disposables.add(
on(this, 'dblclick', () => {
const edgeless = this.std.view.getBlock(this.std.store.root?.id || '');
if (edgeless && !this.model.isLocked()) {
mountFrameTitleEditor(this.model, edgeless);
} else {
this.gfx.selection.set({
elements: [this.model.id],
});
}
})
);
this._zoom = gfx.viewport.zoom;
const updateTitle = () => {
this._frameTitle = this.model.props.title.toString().trim();
};
_disposables.add(() => {
this.model.props.title.yText.unobserve(updateTitle);
});
this.model.props.title.yText.observe(updateTitle);
this._frameTitle = this.model.props.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;
}

View File

@@ -0,0 +1,4 @@
export * from './affine-frame-title-widget.js';
export * from './edgeless-frame-title-editor.js';
export * from './mount-frame-title-editor.js';
export * from './styles.js';

View File

@@ -0,0 +1,34 @@
import { DefaultTool } from '@blocksuite/affine-block-surface';
import type { FrameBlockModel } from '@blocksuite/affine-model';
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import type { BlockComponent } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { EdgelessFrameTitleEditor } from './edgeless-frame-title-editor';
export function mountFrameTitleEditor(
frame: FrameBlockModel,
edgeless: BlockComponent
) {
const mountElm = edgeless.querySelector('.edgeless-mount-point');
if (!mountElm) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.ValueNotExists,
"edgeless block's mount point does not exist"
);
}
const gfx = edgeless.std.get(GfxControllerIdentifier);
gfx.tool.setTool(DefaultTool);
gfx.selection.set({
elements: [frame.id],
editing: true,
});
const frameEditor = new EdgelessFrameTitleEditor();
frameEditor.frameModel = frame;
frameEditor.edgeless = edgeless;
mountElm.append(frameEditor);
}

View File

@@ -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%);
}
`;

View File

@@ -0,0 +1,23 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { frameTitleWidget } from './affine-frame-title-widget';
import { effects } from './effects';
export class FrameTitleViewExtension extends ViewExtensionProvider {
override name = 'affine-frame-title-widget';
override effect() {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
if (context.scope === 'edgeless') {
context.register(frameTitleWidget);
}
}
}

View File

@@ -0,0 +1,19 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/surface" },
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" }
]
}

View File

@@ -0,0 +1,52 @@
{
"name": "@blocksuite/affine-widget-keyboard-toolbar",
"description": "Affine keyboard toolbar widget.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-attachment": "workspace:*",
"@blocksuite/affine-block-database": "workspace:*",
"@blocksuite/affine-block-embed": "workspace:*",
"@blocksuite/affine-block-image": "workspace:*",
"@blocksuite/affine-block-latex": "workspace:*",
"@blocksuite/affine-block-list": "workspace:*",
"@blocksuite/affine-block-note": "workspace:*",
"@blocksuite/affine-block-paragraph": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-block-surface-ref": "workspace:*",
"@blocksuite/affine-block-table": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-fragment-doc-title": "workspace:*",
"@blocksuite/affine-inline-latex": "workspace:*",
"@blocksuite/affine-inline-link": "workspace:*",
"@blocksuite/affine-inline-preset": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/data-view": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"lit": "^3.2.0"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
import {
AFFINE_KEYBOARD_TOOLBAR_WIDGET,
AffineKeyboardToolbarWidget,
} from './index.js';
import {
AFFINE_KEYBOARD_TOOL_PANEL,
AffineKeyboardToolPanel,
} from './keyboard-tool-panel.js';
import {
AFFINE_KEYBOARD_TOOLBAR,
AffineKeyboardToolbar,
} from './keyboard-toolbar.js';
export function effects() {
customElements.define(
AFFINE_KEYBOARD_TOOLBAR_WIDGET,
AffineKeyboardToolbarWidget
);
customElements.define(AFFINE_KEYBOARD_TOOLBAR, AffineKeyboardToolbar);
customElements.define(AFFINE_KEYBOARD_TOOL_PANEL, AffineKeyboardToolPanel);
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_KEYBOARD_TOOLBAR]: AffineKeyboardToolbar;
[AFFINE_KEYBOARD_TOOL_PANEL]: AffineKeyboardToolPanel;
}
}

View File

@@ -0,0 +1,138 @@
import {
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
} from '@blocksuite/icons/lit';
import { cssVarV2 } from '@toeverything/theme/v2';
import { html } from 'lit';
export function HeadingIcon(i: 1 | 2 | 3 | 4 | 5 | 6) {
switch (i) {
case 1:
return Heading1Icon();
case 2:
return Heading2Icon();
case 3:
return Heading3Icon();
case 4:
return Heading4Icon();
case 5:
return Heading5Icon();
case 6:
return Heading6Icon();
default:
return Heading1Icon();
}
}
export const HighLightDuotoneIcon = (color: string) =>
html`<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M5.8291 16.441L7.91757 18.5295L6.57811 19.8689C6.53119 19.9158 6.46406 19.9364 6.3989 19.9239L3.37036 19.3412C3.21285 19.3109 3.15331 19.1168 3.26673 19.0034L5.8291 16.441Z"
fill="${color}"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M19.0095 3.63759C17.9526 2.58067 16.26 2.516 15.1255 3.48919L7.32135 10.1837C6.35438 11.0132 6.05275 12.3823 6.58163 13.5414L6.73501 13.8775L5.67697 14.9356C5.30169 15.3108 5.30169 15.9193 5.67697 16.2946L8.06379 18.6814C8.43907 19.0567 9.04752 19.0567 9.4228 18.6814L10.4808 17.6234L10.8171 17.7768C11.9761 18.3057 13.3452 18.0041 14.1747 17.0371L20.8692 9.23294C21.8424 8.09846 21.7778 6.40588 20.7208 5.34896L19.0095 3.63759ZM16.1021 4.62769C16.6415 4.16498 17.4463 4.19572 17.9488 4.69825L19.6602 6.40962C20.1627 6.91215 20.1935 7.7169 19.7307 8.25631L14.6424 14.188L10.1704 9.71604L16.1021 4.62769ZM9.02857 10.6955L8.29798 11.3222C7.83822 11.7166 7.6948 12.3676 7.94627 12.9187L8.29785 13.6892C8.4348 13.9893 8.37947 14.3544 8.13372 14.6001L7.11878 15.6151L8.74329 17.2396L9.75812 16.2247C10.004 15.9789 10.3691 15.9236 10.6693 16.0606L11.4398 16.4122C11.9908 16.6636 12.6418 16.5202 13.0362 16.0605L13.6629 15.3299L9.02857 10.6955Z"
fill="${cssVarV2('icon/primary')}"
/>
</svg>`;
export const TextColorIcon = (color: string) =>
html`<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.0627 6.16255C14.385 5.30291 15.2068 4.7334 16.1249 4.7334C17.043 4.7334 17.8648 5.30291 18.1872 6.16255L23.7279 20.9378C23.9219 21.455 23.6599 22.0314 23.1427 22.2253C22.6256 22.4192 22.0492 22.1572 21.8553 21.6401L20.2289 17.3031H12.021L10.3946 21.6401C10.2007 22.1572 9.62428 22.4192 9.10716 22.2253C8.59004 22.0314 8.32803 21.455 8.52195 20.9378L14.0627 6.16255ZM12.771 15.3031H19.4789L16.3146 6.8648C16.2849 6.78576 16.2094 6.7334 16.1249 6.7334C16.0405 6.7334 15.965 6.78576 15.9353 6.8648L12.771 15.3031Z"
fill="${cssVarV2('icon/primary')}"
/>
<rect
x="5.45837"
y="24"
width="21.3333"
height="3.33333"
rx="1"
fill=${color}
/>
</svg>`;
export const TextBackgroundDuotoneIcon = (color: string) =>
html`<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.57507 7.33336C4.57507 5.60287 5.97791 4.20003 7.70841 4.20003H25.0417C26.7722 4.20003 28.1751 5.60287 28.1751 7.33336V24.6667C28.1751 26.3972 26.7722 27.8 25.0417 27.8H7.70841C5.97791 27.8 4.57507 26.3972 4.57507 24.6667V7.33336Z"
fill="${color}"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.57495 7.33333C4.57495 5.60284 5.97779 4.2 7.70828 4.2H25.0416C26.7721 4.2 28.175 5.60284 28.175 7.33333V24.6667C28.175 26.3972 26.7721 27.8 25.0416 27.8H7.70828C5.97779 27.8 4.57495 26.3972 4.57495 24.6667V7.33333ZM7.70828 5.13333C6.49326 5.13333 5.50828 6.1183 5.50828 7.33333V24.6667C5.50828 25.8817 6.49326 26.8667 7.70828 26.8667H25.0416C26.2566 26.8667 27.2416 25.8817 27.2416 24.6667V7.33333C27.2416 6.1183 26.2566 5.13333 25.0416 5.13333H7.70828Z"
fill="black"
fill-opacity="0.22"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5379 10.0064C14.8251 9.24064 15.5571 8.73332 16.375 8.73332C17.1928 8.73332 17.9249 9.24064 18.2121 10.0064L22.6446 21.8266C22.8386 22.3438 22.5766 22.9202 22.0594 23.1141C21.5423 23.308 20.9659 23.046 20.772 22.5289L19.5196 19.1891H13.2304L11.978 22.5289C11.7841 23.046 11.2076 23.308 10.6905 23.1141C10.1734 22.9202 9.9114 22.3438 10.1053 21.8266L14.5379 10.0064ZM13.9804 17.1891H18.7696L16.375 10.8035L13.9804 17.1891Z"
fill="${cssVarV2('text/primary')}"
/>
</svg>`;
export const FigmaDuotoneIcon = html`<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Figma_Duotone">
<path
id="Vector"
d="M8.41842 22.5027C10.3047 22.5027 11.8356 20.9719 11.8356 19.0856V15.6685H8.41842C6.53216 15.6685 5.00128 17.1993 5.00128 19.0856C5.00128 20.9719 6.53216 22.5027 8.41842 22.5027Z"
fill="#0ACF83"
/>
<path
id="Vector_2"
d="M5.00128 12.2514C5.00128 10.3651 6.53216 8.83423 8.41842 8.83423H11.8356V15.6685H8.41842C6.53216 15.6685 5.00128 14.1376 5.00128 12.2514Z"
fill="#A259FF"
/>
<path
id="Vector_3"
d="M5.00146 5.41714C5.00146 3.53088 6.53234 2 8.4186 2H11.8357V8.83428H8.4186C6.53234 8.83428 5.00146 7.3034 5.00146 5.41714Z"
fill="#F24E1E"
/>
<path
id="Vector_4"
d="M11.8356 2H15.2527C17.139 2 18.6699 3.53088 18.6699 5.41714C18.6699 7.3034 17.139 8.83428 15.2527 8.83428H11.8356V2Z"
fill="#FF7262"
/>
<path
id="Vector_5"
d="M18.6699 12.2514C18.6699 14.1376 17.139 15.6685 15.2527 15.6685C13.3665 15.6685 11.8356 14.1376 11.8356 12.2514C11.8356 10.3651 13.3665 8.83423 15.2527 8.83423C17.139 8.83423 18.6699 10.3651 18.6699 12.2514Z"
fill="#1ABCFE"
/>
</g>
</svg> `;

View File

@@ -0,0 +1,2 @@
export * from './config.js';
export * from './widget.js';

View File

@@ -0,0 +1,88 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/std';
import { html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type {
KeyboardIconType,
KeyboardToolbarActionItem,
KeyboardToolbarContext,
KeyboardToolPanelConfig,
KeyboardToolPanelGroup,
} from './config.js';
import { keyboardToolPanelStyles } from './styles.js';
export const AFFINE_KEYBOARD_TOOL_PANEL = 'affine-keyboard-tool-panel';
@requiredProperties({
context: PropTypes.object,
})
export class AffineKeyboardToolPanel extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = keyboardToolPanelStyles;
private readonly _handleItemClick = (item: KeyboardToolbarActionItem) => {
if (item.disableWhen && item.disableWhen(this.context)) return;
if (item.action) {
Promise.resolve(item.action(this.context)).catch(console.error);
}
};
private _renderGroup(group: KeyboardToolPanelGroup) {
const items = group.items.filter(
item => item.showWhen?.(this.context) ?? true
);
return html`<div class="keyboard-tool-panel-group">
<div class="keyboard-tool-panel-group-header">${group.name}</div>
<div class="keyboard-tool-panel-group-item-container">
${repeat(
items,
item => item.name,
item => this._renderItem(item)
)}
</div>
</div>`;
}
private _renderIcon(icon: KeyboardIconType) {
return typeof icon === 'function' ? icon(this.context) : icon;
}
private _renderItem(item: KeyboardToolbarActionItem) {
return html`<div class="keyboard-tool-panel-item">
<button @click=${() => this._handleItemClick(item)}>
${this._renderIcon(item.icon)}
</button>
<span>${item.name}</span>
</div>`;
}
override render() {
if (!this.config) return nothing;
const groups = this.config.groups
.map(group => (typeof group === 'function' ? group(this.context) : group))
.filter((group): group is KeyboardToolPanelGroup => group !== null);
return html`<div class="affine-keyboard-tool-panel-container">
${repeat(
groups,
group => group.name,
group => this._renderGroup(group)
)}
</div>`;
}
@property({ attribute: false })
accessor config: KeyboardToolPanelConfig | null = null;
@property({ attribute: false })
accessor context!: KeyboardToolbarContext;
}

View File

@@ -0,0 +1,393 @@
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
import { type VirtualKeyboardProviderWithAction } from '@blocksuite/affine-shared/services';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ArrowLeftBigIcon, KeyboardIcon } from '@blocksuite/icons/lit';
import {
BlockComponent,
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/std';
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
import { effect, type Signal, signal } from '@preact/signals-core';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import type {
KeyboardIconType,
KeyboardToolbarConfig,
KeyboardToolbarContext,
KeyboardToolbarItem,
KeyboardToolPanelConfig,
} from './config';
import { keyboardToolbarStyles } from './styles';
import {
isKeyboardSubToolBarConfig,
isKeyboardToolBarActionItem,
isKeyboardToolPanelConfig,
} from './utils';
export const AFFINE_KEYBOARD_TOOLBAR = 'affine-keyboard-toolbar';
@requiredProperties({
config: PropTypes.object,
rootComponent: PropTypes.instanceOf(BlockComponent),
})
export class AffineKeyboardToolbar extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = keyboardToolbarStyles;
get std() {
return this.rootComponent.std;
}
get panelOpened() {
return this._currentPanelIndex$.value !== -1;
}
private get panelHeight() {
if (this.keyboard.visible$.value) {
return `${this.keyboard.height$.value}px`;
}
if (this.panelOpened) {
return `${
this.keyboard.staticHeight$.value !== 0
? this.keyboard.staticHeight$.value
: 330
}px`;
}
return this.keyboard.appTabSafeArea$.value;
}
/**
* Prevent flickering during keyboard opening
*/
private _resetPanelIndexTimeoutId: ReturnType<typeof setTimeout> | null =
null;
private readonly _closeToolPanel = () => {
if (!this.keyboard.visible$.peek()) this.keyboard.show();
if (this._resetPanelIndexTimeoutId) {
clearTimeout(this._resetPanelIndexTimeoutId);
this._resetPanelIndexTimeoutId = null;
}
this._resetPanelIndexTimeoutId = setTimeout(() => {
this._currentPanelIndex$.value = -1;
}, 100);
};
private readonly _currentPanelIndex$ = signal(-1);
private readonly _goPrevToolbar = () => {
if (!this._isSubToolbarOpened) return;
if (this.panelOpened) this._closeToolPanel();
this._path$.value = this._path$.value.slice(0, -1);
};
private readonly _handleItemClick = (
item: KeyboardToolbarItem,
index: number
) => {
if (isKeyboardToolBarActionItem(item)) {
item.action &&
Promise.resolve(item.action(this._context)).catch(console.error);
} else if (isKeyboardSubToolBarConfig(item)) {
this._closeToolPanel();
this._path$.value = [...this._path$.value, index];
} else if (isKeyboardToolPanelConfig(item)) {
if (this._currentPanelIndex$.value === index) {
this._closeToolPanel();
} else {
if (this._resetPanelIndexTimeoutId) {
clearTimeout(this._resetPanelIndexTimeoutId);
this._resetPanelIndexTimeoutId = null;
}
this._currentPanelIndex$.value = index;
this.keyboard.hide();
this._scrollCurrentBlockIntoView();
}
}
this._lastActiveItem$.value = item;
};
private readonly _lastActiveItem$ = signal<KeyboardToolbarItem | null>(null);
private readonly _path$ = signal<number[]>([]);
private readonly _scrollCurrentBlockIntoView = () => {
this.std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(({ selectedModels }) => {
if (!selectedModels?.length) return;
const block = this.std.view.getBlock(selectedModels[0].id);
if (!block) return;
const { y: y1 } = this.getBoundingClientRect();
const { bottom: y2 } = block.getBoundingClientRect();
const gap = 8;
if (y2 < y1 + gap) return;
scrollTo({
top: window.scrollY + y2 - y1 + gap,
behavior: 'instant',
});
})
.run();
};
private get _context(): KeyboardToolbarContext {
return {
std: this.std,
rootComponent: this.rootComponent,
closeToolPanel: () => {
this._closeToolPanel();
},
};
}
private get _currentPanelConfig(): KeyboardToolPanelConfig | null {
if (!this.panelOpened) return null;
const result = this._currentToolbarItems[this._currentPanelIndex$.value];
return isKeyboardToolPanelConfig(result) ? result : null;
}
private get _currentToolbarItems(): KeyboardToolbarItem[] {
let items = this.config.items;
for (const index of this._path$.value) {
if (isKeyboardSubToolBarConfig(items[index])) {
items = items[index].items;
} else {
break;
}
}
return items.filter(item =>
isKeyboardToolBarActionItem(item)
? (item.showWhen?.(this._context) ?? true)
: true
);
}
private get _isSubToolbarOpened() {
return this._path$.value.length > 0;
}
private _renderIcon(icon: KeyboardIconType) {
return typeof icon === 'function' ? icon(this._context) : icon;
}
private _renderItem(item: KeyboardToolbarItem, index: number) {
let icon = item.icon;
let style = styleMap({});
const disabled =
('disableWhen' in item && item.disableWhen?.(this._context)) ?? false;
if (isKeyboardToolBarActionItem(item)) {
const background =
typeof item.background === 'function'
? item.background(this._context)
: item.background;
if (background)
style = styleMap({
background: background,
});
} else if (isKeyboardToolPanelConfig(item)) {
const { activeIcon, activeBackground } = item;
const active = this._currentPanelIndex$.value === index;
if (active && activeIcon) icon = activeIcon;
if (active && activeBackground)
style = styleMap({ background: activeBackground });
}
return html`<icon-button
size="36px"
style=${style}
?disabled=${disabled}
@click=${() => {
this._handleItemClick(item, index);
}}
>
${this._renderIcon(icon)}
</icon-button>`;
}
private _renderItems() {
if (!this.std.event.active$.value)
return html`<div class="item-container"></div>`;
const goPrevToolbarAction = when(
this._isSubToolbarOpened,
() =>
html`<icon-button size="36px" @click=${this._goPrevToolbar}>
${ArrowLeftBigIcon()}
</icon-button>`
);
return html`<div class="item-container">
${goPrevToolbarAction}
${repeat(this._currentToolbarItems, (item, index) =>
this._renderItem(item, index)
)}
</div>`;
}
private _renderKeyboardButton() {
return html`<div class="keyboard-container">
<icon-button
size="36px"
@click=${() => {
if (this.keyboard.staticHeight$.value === 0) {
this._closeToolPanel();
return;
}
if (this.keyboard.visible$.peek()) {
this.keyboard.hide();
} else {
this.keyboard.show();
}
}}
>
${KeyboardIcon()}
</icon-button>
</div>`;
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
this._disposables.add(
effect(() => {
this.toggleAttribute(
'data-keyboard-visible',
this.keyboard.visible$.value
);
this.toggleAttribute('data-panel-open', this.panelOpened);
})
);
// prevent editor blur when click item in toolbar
this.disposables.addFromEvent(this, 'pointerdown', e => {
e.preventDefault();
});
this.disposables.add(
effect(() => {
const std = this.rootComponent.std;
void std.selection.value;
// wait cursor updated
requestAnimationFrame(() => {
this._scrollCurrentBlockIntoView();
});
})
);
this.disposables.add(
effect(() => {
// sometime the keyboard will auto show when user click into different paragraph in Android,
// so we need to close the tool panel explicitly when the keyboard is visible
if (this.keyboard.visible$.value) {
this._closeToolPanel();
}
})
);
this._watchAutoShow();
this.disposables.add(() => {
if (this._resetPanelIndexTimeoutId) {
clearTimeout(this._resetPanelIndexTimeoutId);
this._resetPanelIndexTimeoutId = null;
}
});
}
private _watchAutoShow() {
const autoShowSubToolbars: { path: number[]; signal: Signal<boolean> }[] =
[];
const traverse = (item: KeyboardToolbarItem, path: number[]) => {
if (isKeyboardSubToolBarConfig(item) && item.autoShow) {
autoShowSubToolbars.push({
path,
signal: item.autoShow(this._context),
});
item.items.forEach((subItem, index) => {
traverse(subItem, [...path, index]);
});
}
};
this.config.items.forEach((item, index) => {
traverse(item, [index]);
});
const samePath = (a: number[], b: number[]) =>
a.length === b.length && a.every((v, i) => v === b[i]);
let prevPath = this._path$.peek();
this.disposables.add(
effect(() => {
autoShowSubToolbars.forEach(({ path, signal }) => {
if (signal.value) {
if (samePath(this._path$.peek(), path)) return;
prevPath = this._path$.peek();
this._path$.value = path;
} else {
this._path$.value = prevPath;
}
});
})
);
}
override firstUpdated() {
// workaround for the virtual keyboard showing transition animation
const timeoutId = setTimeout(() => {
this._scrollCurrentBlockIntoView();
}, 700);
this.disposables.add(() => {
clearTimeout(timeoutId);
});
}
override render() {
return html`
<div class="keyboard-toolbar">
${this._renderItems()}
<div class="divider"></div>
${this._renderKeyboardButton()}
</div>
<affine-keyboard-tool-panel
.config=${this._currentPanelConfig}
.context=${this._context}
style=${styleMap({
height: this.panelHeight,
paddingBottom: this.keyboard.appTabSafeArea$.value,
})}
></affine-keyboard-tool-panel>
`;
}
@property({ attribute: false })
accessor keyboard!: VirtualKeyboardProviderWithAction;
@property({ attribute: false })
accessor config!: KeyboardToolbarConfig;
@property({ attribute: false })
accessor rootComponent!: BlockComponent;
}

Some files were not shown because too many files have changed in this diff Show More