refactor(editor): extract root block (#10356)

Closes: [BS-2207](https://linear.app/affine-design/issue/BS-2207/move-root-block-to-affineblock-root)
This commit is contained in:
Saul-Mirone
2025-02-21 12:38:26 +00:00
parent 4a66ec7400
commit 55651503df
336 changed files with 626 additions and 423 deletions
@@ -0,0 +1,11 @@
import type { ExtensionType } from '@blocksuite/store';
import { RootBlockHtmlAdapterExtension } from './html.js';
import { RootBlockMarkdownAdapterExtension } from './markdown.js';
import { RootBlockNotionHtmlAdapterExtension } from './notion-html.js';
export const RootBlockAdapterExtensions: ExtensionType[] = [
RootBlockHtmlAdapterExtension,
RootBlockMarkdownAdapterExtension,
RootBlockNotionHtmlAdapterExtension,
];
@@ -0,0 +1,135 @@
import { RootBlockSchema } from '@blocksuite/affine-model';
import {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
HastUtils,
} from '@blocksuite/affine-shared/adapters';
export const rootBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: RootBlockSchema.model.flavour,
toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'header',
fromMatch: o => o.node.flavour === RootBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext } = context;
if (o.node.tagName === 'header') {
walkerContext.skipAllChildren();
}
},
},
fromBlockSnapshot: {
enter: (_, context) => {
const { walkerContext } = context;
const htmlRootDocContext =
walkerContext.getGlobalContext('hast:html-root-doc');
const isRootDoc = htmlRootDocContext ?? true;
if (!isRootDoc) {
return;
}
walkerContext
.openNode(
{
type: 'element',
tagName: 'html',
properties: {},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: 'head',
properties: {},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: 'style',
properties: {},
children: [],
},
'children'
)
.openNode(
{
type: 'text',
value: `
input[type='checkbox'] {
display: none;
}
label:before {
background: rgb(30, 150, 235);
border-radius: 3px;
height: 16px;
width: 16px;
display: inline-block;
cursor: pointer;
}
input[type='checkbox'] + label:before {
content: '';
background: rgb(30, 150, 235);
color: #fff;
font-size: 16px;
line-height: 16px;
text-align: center;
}
input[type='checkbox']:checked + label:before {
content: '✓';
}
`.replace(/\s\s+/g, ''),
},
'children'
)
.closeNode()
.closeNode()
.closeNode()
.openNode(
{
type: 'element',
tagName: 'body',
properties: {},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: 'div',
properties: {
style: 'width: 70vw; margin: 60px auto;',
},
children: [],
},
'children'
)
.openNode({
type: 'comment',
value: 'BlockSuiteDocTitlePlaceholder',
})
.closeNode();
},
leave: (_, context) => {
const { walkerContext } = context;
const htmlRootDocContext =
walkerContext.getGlobalContext('hast:html-root-doc');
const isRootDoc = htmlRootDocContext ?? true;
if (!isRootDoc) {
return;
}
walkerContext.closeNode().closeNode().closeNode();
},
},
};
export const RootBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
rootBlockHtmlAdapterMatcher
);
@@ -0,0 +1,3 @@
export * from './html.js';
export * from './markdown.js';
export * from './notion-html.js';
@@ -0,0 +1,36 @@
import { RootBlockSchema } from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
import type { DeltaInsert } from '@blocksuite/inline';
export const rootBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
flavour: RootBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === RootBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: (o, context) => {
const title = (o.node.props.title ?? { delta: [] }) as {
delta: DeltaInsert[];
};
const { walkerContext, deltaConverter } = context;
if (title.delta.length === 0) return;
walkerContext
.openNode(
{
type: 'heading',
depth: 1,
children: deltaConverter.deltaToAST(title.delta, 0),
},
'children'
)
.closeNode();
},
},
};
export const RootBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
rootBlockMarkdownAdapterMatcher
);
@@ -0,0 +1,28 @@
import { RootBlockSchema } from '@blocksuite/affine-model';
import {
BlockNotionHtmlAdapterExtension,
type BlockNotionHtmlAdapterMatcher,
HastUtils,
} from '@blocksuite/affine-shared/adapters';
export const rootBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
{
flavour: RootBlockSchema.model.flavour,
toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'header',
fromMatch: () => false,
toBlockSnapshot: {
enter: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext } = context;
if (o.node.tagName === 'header') {
walkerContext.skipAllChildren();
}
},
},
fromBlockSnapshot: {},
};
export const RootBlockNotionHtmlAdapterExtension =
BlockNotionHtmlAdapterExtension(rootBlockNotionHtmlAdapterMatcher);
@@ -0,0 +1,92 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { assertExists } from '@blocksuite/global/utils';
import type {
BlockSnapshot,
DocSnapshot,
FromBlockSnapshotPayload,
FromBlockSnapshotResult,
FromDocSnapshotPayload,
FromDocSnapshotResult,
FromSliceSnapshotPayload,
FromSliceSnapshotResult,
SliceSnapshot,
ToBlockSnapshotPayload,
ToDocSnapshotPayload,
ToSliceSnapshotPayload,
} from '@blocksuite/store';
import { BaseAdapter } from '@blocksuite/store';
import { decodeClipboardBlobs, encodeClipboardBlobs } from './utils.js';
export type FileSnapshot = {
name: string;
type: string;
content: string;
};
export class ClipboardAdapter extends BaseAdapter<string> {
static MIME = 'BLOCKSUITE/SNAPSHOT';
override fromBlockSnapshot(
_payload: FromBlockSnapshotPayload
): Promise<FromBlockSnapshotResult<string>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ClipboardAdapter.fromBlockSnapshot is not implemented'
);
}
override fromDocSnapshot(
_payload: FromDocSnapshotPayload
): Promise<FromDocSnapshotResult<string>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ClipboardAdapter.fromDocSnapshot is not implemented'
);
}
override async fromSliceSnapshot(
payload: FromSliceSnapshotPayload
): Promise<FromSliceSnapshotResult<string>> {
const snapshot = payload.snapshot;
const assets = payload.assets;
assertExists(assets);
const map = assets.getAssets();
const blobs: Record<string, FileSnapshot> = await encodeClipboardBlobs(map);
return {
file: JSON.stringify({
snapshot,
blobs,
}),
assetsIds: [],
};
}
override toBlockSnapshot(
_payload: ToBlockSnapshotPayload<string>
): Promise<BlockSnapshot> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ClipboardAdapter.toBlockSnapshot is not implemented'
);
}
override toDocSnapshot(
_payload: ToDocSnapshotPayload<string>
): Promise<DocSnapshot> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'ClipboardAdapter.toDocSnapshot is not implemented'
);
}
override toSliceSnapshot(
payload: ToSliceSnapshotPayload<string>
): Promise<SliceSnapshot> {
const json = JSON.parse(payload.file);
const { blobs, snapshot } = json;
const map = payload.assets?.getAssets();
decodeClipboardBlobs(blobs, map);
return Promise.resolve(snapshot);
}
}
@@ -0,0 +1,2 @@
export * from './page-clipboard.js';
export * from './readonly-clipboard.js';
@@ -0,0 +1,169 @@
import { deleteTextCommand } from '@blocksuite/affine-components/rich-text';
import {
pasteMiddleware,
replaceIdMiddleware,
surfaceRefToEmbed,
} from '@blocksuite/affine-shared/adapters';
import {
clearAndSelectFirstModelCommand,
deleteSelectedModelsCommand,
getBlockIndexCommand,
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSelectedModelsCommand,
getTextSelectionCommand,
retainFirstModelCommand,
} from '@blocksuite/affine-shared/commands';
import type { UIEventHandler } from '@blocksuite/block-std';
import { DisposableGroup } from '@blocksuite/global/utils';
import type { BlockSnapshot, Store } from '@blocksuite/store';
import { ReadOnlyClipboard } from './readonly-clipboard';
/**
* PageClipboard is a class that provides a clipboard for the page root block.
* It is supported to copy and paste models in the page root block.
*/
export class PageClipboard extends ReadOnlyClipboard {
protected _init = () => {
this._initAdapters();
const paste = pasteMiddleware(this._std);
// Use surfaceRefToEmbed middleware to convert surface-ref to embed-linked-doc
// When pastina a surface-ref block to another doc
const surfaceRefToEmbedMiddleware = surfaceRefToEmbed(this._std);
const replaceId = replaceIdMiddleware(
this._std.store.workspace.idGenerator
);
this._std.clipboard.use(paste);
this._std.clipboard.use(surfaceRefToEmbedMiddleware);
this._std.clipboard.use(replaceId);
this._disposables.add({
dispose: () => {
this._std.clipboard.unuse(paste);
this._std.clipboard.unuse(surfaceRefToEmbedMiddleware);
this._std.clipboard.unuse(replaceId);
},
});
};
onBlockSnapshotPaste = async (
snapshot: BlockSnapshot,
doc: Store,
parent?: string,
index?: number
) => {
const block = await this._std.clipboard.pasteBlockSnapshot(
snapshot,
doc,
parent,
index
);
return block?.id ?? null;
};
onPageCut: UIEventHandler = ctx => {
const e = ctx.get('clipboardState').raw;
e.preventDefault();
this._copySelected(() => {
this._std.command
.chain()
.try<{}>(cmd => [
cmd.pipe(getTextSelectionCommand).pipe(deleteTextCommand),
cmd.pipe(getSelectedModelsCommand).pipe(deleteSelectedModelsCommand),
])
.run();
}).run();
};
onPagePaste: UIEventHandler = ctx => {
const e = ctx.get('clipboardState').raw;
e.preventDefault();
this._std.store.captureSync();
this._std.command
.chain()
.try<{}>(cmd => [
cmd.pipe(getTextSelectionCommand).pipe((ctx, next) => {
const { currentTextSelection } = ctx;
if (!currentTextSelection) {
return;
}
const { from, to } = currentTextSelection;
if (to && from.blockId !== to.blockId) {
this._std.command.exec(deleteTextCommand, {
currentTextSelection,
});
}
return next();
}),
cmd
.pipe(getSelectedModelsCommand)
.pipe(clearAndSelectFirstModelCommand)
.pipe(retainFirstModelCommand)
.pipe(deleteSelectedModelsCommand),
])
.try<{ currentSelectionPath: string }>(cmd => [
cmd.pipe(getTextSelectionCommand).pipe((ctx, next) => {
const textSelection = ctx.currentTextSelection;
if (!textSelection) {
return;
}
next({ currentSelectionPath: textSelection.from.blockId });
}),
cmd.pipe(getBlockSelectionsCommand).pipe((ctx, next) => {
const currentBlockSelections = ctx.currentBlockSelections;
if (!currentBlockSelections) {
return;
}
const blockSelection = currentBlockSelections.at(-1);
if (!blockSelection) {
return;
}
next({ currentSelectionPath: blockSelection.blockId });
}),
cmd.pipe(getImageSelectionsCommand).pipe((ctx, next) => {
const currentImageSelections = ctx.currentImageSelections;
if (!currentImageSelections) {
return;
}
const imageSelection = currentImageSelections.at(-1);
if (!imageSelection) {
return;
}
next({ currentSelectionPath: imageSelection.blockId });
}),
])
.pipe(getBlockIndexCommand)
.pipe((ctx, next) => {
if (!ctx.parentBlock) {
return;
}
this._std.clipboard
.paste(
e,
this._std.store,
ctx.parentBlock.model.id,
ctx.blockIndex ? ctx.blockIndex + 1 : 1
)
.catch(console.error);
return next();
})
.run();
};
override hostConnected() {
if (this._disposables.disposed) {
this._disposables = new DisposableGroup();
}
if (navigator.clipboard) {
this.host.handleEvent('copy', this.onPageCopy);
this.host.handleEvent('paste', this.onPagePaste);
this.host.handleEvent('cut', this.onPageCut);
this._init();
}
}
}
export { pasteMiddleware };
@@ -0,0 +1,125 @@
import { defaultImageProxyMiddleware } from '@blocksuite/affine-block-image';
import {
AttachmentAdapter,
copyMiddleware,
HtmlAdapter,
ImageAdapter,
MixTextAdapter,
NotionTextAdapter,
titleMiddleware,
} from '@blocksuite/affine-shared/adapters';
import {
copySelectedModelsCommand,
draftSelectedModelsCommand,
getSelectedModelsCommand,
} from '@blocksuite/affine-shared/commands';
import type { BlockComponent, UIEventHandler } from '@blocksuite/block-std';
import { DisposableGroup } from '@blocksuite/global/utils';
import { ClipboardAdapter } from './adapter.js';
/**
* ReadOnlyClipboard is a class that provides a read-only clipboard for the root block.
* It is supported to copy models in the root block.
*/
export class ReadOnlyClipboard {
protected readonly _copySelected = (onCopy?: () => void) => {
return this._std.command
.chain()
.with({ onCopy })
.pipe(getSelectedModelsCommand)
.pipe(draftSelectedModelsCommand)
.pipe(copySelectedModelsCommand);
};
protected _disposables = new DisposableGroup();
protected _initAdapters = () => {
this._std.clipboard.registerAdapter(
ClipboardAdapter.MIME,
ClipboardAdapter,
100
);
this._std.clipboard.registerAdapter(
'text/_notion-text-production',
NotionTextAdapter,
95
);
this._std.clipboard.registerAdapter('text/html', HtmlAdapter, 90);
[
'image/apng',
'image/avif',
'image/gif',
'image/jpeg',
'image/png',
'image/svg+xml',
'image/webp',
].forEach(type =>
this._std.clipboard.registerAdapter(type, ImageAdapter, 80)
);
this._std.clipboard.registerAdapter('text/plain', MixTextAdapter, 70);
this._std.clipboard.registerAdapter('*/*', AttachmentAdapter, 60);
const copy = copyMiddleware(this._std);
this._std.clipboard.use(copy);
this._std.clipboard.use(
titleMiddleware(this._std.store.workspace.meta.docMetas)
);
this._std.clipboard.use(defaultImageProxyMiddleware);
this._disposables.add({
dispose: () => {
this._std.clipboard.unregisterAdapter(ClipboardAdapter.MIME);
this._std.clipboard.unregisterAdapter('text/plain');
[
'image/apng',
'image/avif',
'image/gif',
'image/jpeg',
'image/png',
'image/svg+xml',
'image/webp',
].forEach(type => this._std.clipboard.unregisterAdapter(type));
this._std.clipboard.unregisterAdapter('text/html');
this._std.clipboard.unregisterAdapter('*/*');
this._std.clipboard.unuse(copy);
this._std.clipboard.unuse(
titleMiddleware(this._std.store.workspace.meta.docMetas)
);
this._std.clipboard.unuse(defaultImageProxyMiddleware);
},
});
};
host: BlockComponent;
onPageCopy: UIEventHandler = ctx => {
const e = ctx.get('clipboardState').raw;
e.preventDefault();
this._copySelected().run();
};
protected get _std() {
return this.host.std;
}
constructor(host: BlockComponent) {
this.host = host;
}
hostConnected() {
if (this._disposables.disposed) {
this._disposables = new DisposableGroup();
}
if (navigator.clipboard) {
this.host.handleEvent('copy', this.onPageCopy);
this._initAdapters();
}
}
hostDisconnected() {
this._disposables.dispose();
}
}
export { copyMiddleware };
@@ -0,0 +1,124 @@
import { toast } from '@blocksuite/affine-components/toast';
import { assertExists } from '@blocksuite/global/utils';
import type { FileSnapshot } from './adapter.js';
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
// Use a lookup table to find the index.
const lookup = typeof Uint8Array === 'undefined' ? [] : new Uint8Array(256);
for (let i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
export const encode = (arraybuffer: ArrayBuffer): string => {
const bytes = new Uint8Array(arraybuffer);
const len = bytes.length;
let i,
base64 = '';
for (i = 0; i < len; i += 3) {
base64 += chars[bytes[i] >> 2];
base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
base64 += chars[bytes[i + 2] & 63];
}
if (len % 3 === 2) {
base64 = base64.substring(0, base64.length - 1) + '=';
} else if (len % 3 === 1) {
base64 = base64.substring(0, base64.length - 2) + '==';
}
return base64;
};
export const decode = (base64: string): ArrayBuffer => {
const len = base64.length;
let bufferLength = base64.length * 0.75,
i,
p = 0,
encoded1,
encoded2,
encoded3,
encoded4;
if (base64[base64.length - 1] === '=') {
bufferLength--;
if (base64[base64.length - 2] === '=') {
bufferLength--;
}
}
const arraybuffer = new ArrayBuffer(bufferLength),
bytes = new Uint8Array(arraybuffer);
for (i = 0; i < len; i += 4) {
encoded1 = lookup[base64.charCodeAt(i)];
encoded2 = lookup[base64.charCodeAt(i + 1)];
encoded3 = lookup[base64.charCodeAt(i + 2)];
encoded4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
return arraybuffer;
};
export async function encodeClipboardBlobs(map: Map<string, Blob>) {
const blobs: Record<string, FileSnapshot> = {};
let sumSize = 0;
await Promise.all(
Array.from(map.entries()).map(async ([id, blob]) => {
if (blob.size > 4 * 1024 * 1024) {
const host = document.querySelector('editor-host');
if (!host) {
return;
}
toast(
host,
(blob as File).name ?? 'File' + ' is too large to be copied'
);
return;
}
sumSize += blob.size;
if (sumSize > 6 * 1024 * 1024) {
const host = document.querySelector('editor-host');
if (!host) {
return;
}
toast(
host,
(blob as File).name ??
'File' + ' cannot be copied due to the clipboard size limit'
);
return;
}
const content = encode(await blob.arrayBuffer());
const file: FileSnapshot = {
name: (blob as File).name,
type: blob.type,
content,
};
blobs[id] = file;
})
);
return blobs;
}
export function decodeClipboardBlobs(
blobs: Record<string, FileSnapshot>,
map: Map<string, Blob> | undefined
) {
Object.entries<FileSnapshot>(blobs).forEach(([sourceId, file]) => {
const blob = new Blob([decode(file.content)]);
const f = new File([blob], file.name, {
type: file.type,
});
assertExists(map);
map.set(sourceId, f);
});
}
@@ -0,0 +1,48 @@
import { FileDropExtension } from '@blocksuite/affine-components/drop-indicator';
import {
DNDAPIExtension,
DocModeService,
EmbedOptionService,
PageViewportServiceExtension,
ThemeService,
} from '@blocksuite/affine-shared/services';
import { FlavourExtension } from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { RootBlockAdapterExtensions } from '../adapters/extension';
import {
docRemoteSelectionWidget,
dragHandleWidget,
embedCardToolbarWidget,
formatBarWidget,
innerModalWidget,
linkedDocWidget,
modalWidget,
scrollAnchoringWidget,
slashMenuWidget,
viewportOverlayWidget,
} from './widgets';
export const CommonSpecs: ExtensionType[] = [
FlavourExtension('affine:page'),
DocModeService,
ThemeService,
EmbedOptionService,
PageViewportServiceExtension,
DNDAPIExtension,
FileDropExtension,
...RootBlockAdapterExtensions,
modalWidget,
innerModalWidget,
slashMenuWidget,
linkedDocWidget,
dragHandleWidget,
embedCardToolbarWidget,
formatBarWidget,
docRemoteSelectionWidget,
viewportOverlayWidget,
scrollAnchoringWidget,
];
export * from './widgets';
@@ -0,0 +1,64 @@
import { AFFINE_DRAG_HANDLE_WIDGET } from '@blocksuite/affine-widget-drag-handle';
import { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from '@blocksuite/affine-widget-remote-selection';
import { AFFINE_SCROLL_ANCHORING_WIDGET } from '@blocksuite/affine-widget-scroll-anchoring';
import { WidgetViewExtension } from '@blocksuite/block-std';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { AFFINE_EMBED_CARD_TOOLBAR_WIDGET } from '../widgets/embed-card-toolbar/embed-card-toolbar.js';
import { AFFINE_FORMAT_BAR_WIDGET } from '../widgets/format-bar/format-bar.js';
import { AFFINE_INNER_MODAL_WIDGET } from '../widgets/inner-modal/inner-modal.js';
import { AFFINE_LINKED_DOC_WIDGET } from '../widgets/linked-doc/config.js';
import { AFFINE_MODAL_WIDGET } from '../widgets/modal/modal.js';
import { AFFINE_SLASH_MENU_WIDGET } from '../widgets/slash-menu/index.js';
import { AFFINE_VIEWPORT_OVERLAY_WIDGET } from '../widgets/viewport-overlay/viewport-overlay.js';
export const modalWidget = WidgetViewExtension(
'affine:page',
AFFINE_MODAL_WIDGET,
literal`${unsafeStatic(AFFINE_MODAL_WIDGET)}`
);
export const innerModalWidget = WidgetViewExtension(
'affine:page',
AFFINE_INNER_MODAL_WIDGET,
literal`${unsafeStatic(AFFINE_INNER_MODAL_WIDGET)}`
);
export const slashMenuWidget = WidgetViewExtension(
'affine:page',
AFFINE_SLASH_MENU_WIDGET,
literal`${unsafeStatic(AFFINE_SLASH_MENU_WIDGET)}`
);
export const linkedDocWidget = WidgetViewExtension(
'affine:page',
AFFINE_LINKED_DOC_WIDGET,
literal`${unsafeStatic(AFFINE_LINKED_DOC_WIDGET)}`
);
export const dragHandleWidget = WidgetViewExtension(
'affine:page',
AFFINE_DRAG_HANDLE_WIDGET,
literal`${unsafeStatic(AFFINE_DRAG_HANDLE_WIDGET)}`
);
export const embedCardToolbarWidget = WidgetViewExtension(
'affine:page',
AFFINE_EMBED_CARD_TOOLBAR_WIDGET,
literal`${unsafeStatic(AFFINE_EMBED_CARD_TOOLBAR_WIDGET)}`
);
export const formatBarWidget = WidgetViewExtension(
'affine:page',
AFFINE_FORMAT_BAR_WIDGET,
literal`${unsafeStatic(AFFINE_FORMAT_BAR_WIDGET)}`
);
export const docRemoteSelectionWidget = WidgetViewExtension(
'affine:page',
AFFINE_DOC_REMOTE_SELECTION_WIDGET,
literal`${unsafeStatic(AFFINE_DOC_REMOTE_SELECTION_WIDGET)}`
);
export const viewportOverlayWidget = WidgetViewExtension(
'affine:page',
AFFINE_VIEWPORT_OVERLAY_WIDGET,
literal`${unsafeStatic(AFFINE_VIEWPORT_OVERLAY_WIDGET)}`
);
export const scrollAnchoringWidget = WidgetViewExtension(
'affine:page',
AFFINE_SCROLL_ANCHORING_WIDGET,
literal`${unsafeStatic(AFFINE_SCROLL_ANCHORING_WIDGET)}`
);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,664 @@
import { insertEdgelessTextCommand } from '@blocksuite/affine-block-edgeless-text';
import {
CanvasElementType,
EdgelessCRUDIdentifier,
} from '@blocksuite/affine-block-surface';
import { FontFamilyIcon } from '@blocksuite/affine-components/icons';
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 } from '@blocksuite/affine-shared/utils';
import { type BlockStdScope, stdContext } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import type { XYWH } from '@blocksuite/global/utils';
import {
assertInstanceOf,
Bound,
clamp,
normalizeDegAngle,
serializeXYWH,
toDegree,
Vec,
WithDisposable,
} from '@blocksuite/global/utils';
import { FrameIcon, PageIcon } from '@blocksuite/icons/lit';
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 { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
import {
SHAPE_OVERLAY_HEIGHT,
SHAPE_OVERLAY_WIDTH,
} from '../../utils/consts.js';
import {
mountShapeTextEditor,
mountTextElementEditor,
} from '../../utils/text.js';
import { ShapeComponentConfig } from '../toolbar/shape/shape-menu-config.js';
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: EdgelessRootBlockComponent,
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);
}
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 { service, surfaceBlockModel } = edgeless;
const frameMgr = service.frame;
const frameIndex = service.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.doc.captureSync();
const frame = this.crud.getElementById(id);
if (!frame) return;
this.connector.target = {
id,
position,
};
edgeless.service.selection.set({
elements: [frame.id],
editing: false,
});
}
private _addNote() {
const { doc } = 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),
},
doc.root?.id
);
const note = doc.getBlock(id)?.model;
assertInstanceOf(note, NoteBlockModel);
doc.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.edgeless.service.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.doc.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.doc
.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.doc.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);
assertInstanceOf(textElement, TextElementModel);
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.doc.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.edgeless.service;
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.edgeless.surface.renderer.removeOverlay(this._overlay);
}
private _showFrameOverlay() {
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.edgeless.surface.renderer.addOverlay(this._overlay);
}
private _showNoteOverlay() {
const xywh = this._getTargetXYWH(
DEFAULT_NOTE_WIDTH,
DEFAULT_NOTE_OVERLAY_HEIGHT
)?.xywh;
if (!xywh) 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.edgeless.surface.renderer.addOverlay(this._overlay);
}
private _showOverlay(targetType: AUTO_COMPLETE_TARGET_TYPE) {
this._removeOverlay();
if (!this._connectorExist()) return;
switch (targetType) {
case 'text':
this._showTextOverlay();
break;
case 'note':
this._showNoteOverlay();
break;
case 'frame':
this._showFrameOverlay();
break;
default:
this._showShapeOverlay(targetType);
}
this.edgeless.surface.refresh();
}
private _showShapeOverlay(targetType: TARGET_SHAPE_TYPE) {
const bound = this._generateTarget(this.connector)?.nextBound;
if (!bound) 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.edgeless.surface.renderer.addOverlay(this._overlay);
}
private _showTextOverlay() {
const xywh = this._getTargetXYWH(
DEFAULT_TEXT_WIDTH,
DEFAULT_TEXT_HEIGHT
)?.xywh;
if (!xywh) return;
this._overlay = new AutoCompleteTextOverlay(this.gfx, xywh);
this.edgeless.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.edgeless.service.viewport.viewportUpdated.on(() =>
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: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor position: [number, number];
@consume({
context: stdContext,
})
accessor std!: BlockStdScope;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-auto-complete-panel': EdgelessAutoCompletePanel;
}
}
@@ -0,0 +1,757 @@
import {
CanvasElementType,
type ConnectionOverlay,
ConnectorPathGenerator,
EdgelessCRUDIdentifier,
isNoteBlock,
Overlay,
OverlayIdentifier,
type RoughCanvas,
} from '@blocksuite/affine-block-surface';
import type {
Connection,
ConnectorElementModel,
NoteBlockModel,
ShapeType,
} from '@blocksuite/affine-model';
import {
DEFAULT_NOTE_HEIGHT,
DefaultTheme,
LayoutType,
MindmapElementModel,
ShapeElementModel,
shapeMethods,
} from '@blocksuite/affine-model';
import { handleNativeRangeAtPoint } from '@blocksuite/affine-shared/utils';
import { type BlockStdScope, stdContext } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import type { Bound, IVec } from '@blocksuite/global/utils';
import {
assertExists,
DisposableGroup,
Vec,
WithDisposable,
} from '@blocksuite/global/utils';
import {
ArrowUpBigIcon,
PlusIcon,
SiblingNodeIcon,
SubNodeIcon,
} from '@blocksuite/icons/lit';
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 type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
import { mountShapeTextEditor } from '../../utils/text.js';
import type { SelectedRect } from '../rects/edgeless-selected-rect.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 _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.dispatcher) 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) {
assertExists(connector);
const otherSideId = connector.source.id;
connector.target = this.connectionOverlay.renderConnector(
point,
otherSideId ? [otherSideId] : []
);
}
});
this._disposables.addFromEvent(document, 'pointerup', e => {
if (!this._isMoving) {
this._generateElementOnClick(type);
} else if (connector && !connector.target.id) {
this.edgeless.service.selection.clear();
this._createAutoCompletePanel(e, connector);
}
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.edgeless.service.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 { doc, service } = 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 = doc.getBlockById(id);
assertExists(model);
const [x, y] = service.viewport.toViewCoord(
bound.center[0],
bound.y + DEFAULT_NOTE_HEIGHT / 2
);
requestAnimationFrame(() => {
handleNativeRangeAtPoint(x, y);
});
}
this.edgeless.service.selection.set({
elements: [id],
editing: true,
});
this.removeOverlay();
}
private _getConnectedElements(element: ShapeElementModel) {
const service = this.edgeless.service;
return service.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 } = this.edgeless;
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.edgeless.service.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.edgeless.service.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 } = this.edgeless;
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 } = this;
_disposables.add(
this.gfx.selection.slots.updated.on(() => {
this._autoCompleteOverlay.linePoints = [];
this._autoCompleteOverlay.renderShape = null;
})
);
_disposables.add(() => this.removeOverlay());
_disposables.add(
edgeless.host.event.add('pointerMove', ctx => {
const evt = ctx.get('pointerState');
const [x, y] = edgeless.gfx.viewport.toModelCoord(evt.x, evt.y);
const elm = edgeless.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;
});
}
removeOverlay() {
this._timer && clearTimeout(this._timer);
this.edgeless.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.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!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor selectedRect!: SelectedRect;
@consume({
context: stdContext,
})
accessor std!: BlockStdScope;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-auto-complete': EdgelessAutoComplete;
}
}
@@ -0,0 +1,351 @@
import {
type Options,
Overlay,
type RoughCanvas,
} from '@blocksuite/affine-block-surface';
import {
type Connection,
getShapeRadius,
getShapeType,
GroupElementModel,
type NoteBlockModel,
ShapeElementModel,
type ShapeName,
type ShapeStyle,
} from '@blocksuite/affine-model';
import type { GfxController, GfxModel } from '@blocksuite/block-std/gfx';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { XYWH } from '@blocksuite/global/utils';
import { assertType, Bound, normalizeDegAngle } from '@blocksuite/global/utils';
import * as Y from 'yjs';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
import { type Shape, ShapeFactory } from '../../utils/tool-overlay.js';
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: EdgelessRootBlockComponent,
current: ShapeElementModel | NoteBlockModel,
bound: Bound
) {
let id;
const { service } = edgeless;
const { crud } = service;
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 { doc } = edgeless;
id = doc.addBlock(
'affine:note',
{
background: current.background,
displayMode: current.displayMode,
edgeless: current.edgeless,
xywh: bound.serialize(),
},
edgeless.model.id
);
const note = doc.getBlock(id)?.model;
if (!note) {
throw new BlockSuiteError(
ErrorCode.GfxBlockElementError,
'Note block is not found after creation'
);
}
assertType<NoteBlockModel>(note);
doc.updateBlock(note, () => {
note.edgeless.collapse = true;
});
doc.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: EdgelessRootBlockComponent,
current: ShapeElementModel | NoteBlockModel,
targetType: TARGET_SHAPE_TYPE
) {
const { crud } = edgeless.service;
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;
}
@@ -0,0 +1,198 @@
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;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-tool-icon-button': EdgelessToolIconButton;
}
}
@@ -0,0 +1,45 @@
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()} `;
}
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-toolbar-button': EdgelessToolbarButton;
}
}
@@ -0,0 +1,168 @@
import {
type ConnectionOverlay,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import {
type BlockStdScope,
docContext,
stdContext,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { DisposableGroup, Vec, WithDisposable } from '@blocksuite/global/utils';
import type { Store } from '@blocksuite/store';
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 { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
const SIZE = 12;
const HALF_SIZE = SIZE / 2;
export class EdgelessConnectorHandle extends WithDisposable(LitElement) {
static override styles = css`
.line-controller {
position: absolute;
width: ${SIZE}px;
height: ${SIZE}px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid var(--affine-text-emphasis-color);
background-color: var(--affine-background-primary-color);
cursor: pointer;
z-index: 10;
pointer-events: all;
/**
* 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;
}
.line-controller-hidden {
display: none;
}
`;
private _lastZoom = 1;
get connectionOverlay() {
return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay;
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
private _bindEvent() {
const edgeless = this.edgeless;
this._disposables.addFromEvent(this._startHandler, 'pointerdown', e => {
edgeless.slots.elementResizeStart.emit();
this._capPointerDown(e, 'source');
});
this._disposables.addFromEvent(this._endHandler, 'pointerdown', e => {
edgeless.slots.elementResizeStart.emit();
this._capPointerDown(e, 'target');
});
this._disposables.add(() => {
this.connectionOverlay.clear();
});
}
private _capPointerDown(e: PointerEvent, connection: 'target' | 'source') {
const { edgeless, connector, _disposables } = this;
const { service } = edgeless;
e.stopPropagation();
_disposables.addFromEvent(document, 'pointermove', e => {
const point = service.viewport.toModelCoordFromClientCoord([e.x, e.y]);
const isStartPointer = connection === 'source';
const otherSideId = connector[isStartPointer ? 'target' : 'source'].id;
connector[connection] = this.connectionOverlay.renderConnector(
point,
otherSideId ? [otherSideId] : []
);
this.requestUpdate();
});
_disposables.addFromEvent(document, 'pointerup', () => {
this.doc.captureSync();
_disposables.dispose();
this._disposables = new DisposableGroup();
this._bindEvent();
edgeless.slots.elementResizeEnd.emit();
});
}
override firstUpdated() {
const { edgeless } = this;
const { viewport } = edgeless.service;
this._lastZoom = viewport.zoom;
edgeless.service.viewport.viewportUpdated.on(() => {
if (viewport.zoom !== this._lastZoom) {
this._lastZoom = viewport.zoom;
this.requestUpdate();
}
});
this._bindEvent();
}
override render() {
const { service } = this.edgeless;
// path is relative to the element's xywh
const { path } = this.connector;
const zoom = service.viewport.zoom;
const startPoint = Vec.subScalar(Vec.mul(path[0], zoom), HALF_SIZE);
const endPoint = Vec.subScalar(
Vec.mul(path[path.length - 1], zoom),
HALF_SIZE
);
const startStyle = {
transform: `translate3d(${startPoint[0]}px,${startPoint[1]}px,0)`,
};
const endStyle = {
transform: `translate3d(${endPoint[0]}px,${endPoint[1]}px,0)`,
};
return html`
<div
class="line-controller line-start"
style=${styleMap(startStyle)}
></div>
<div class="line-controller line-end" style=${styleMap(endStyle)}></div>
`;
}
@query('.line-end')
private accessor _endHandler!: HTMLDivElement;
@query('.line-start')
private accessor _startHandler!: HTMLDivElement;
@property({ attribute: false })
accessor connector!: ConnectorElementModel;
@consume({
context: docContext,
})
accessor doc!: Store;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@consume({
context: stdContext,
})
accessor std!: BlockStdScope;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-connector-handle': EdgelessConnectorHandle;
}
}
@@ -0,0 +1,244 @@
import type { FrameBlockModel } from '@blocksuite/affine-model';
import { SpecProvider } from '@blocksuite/affine-shared/utils';
import {
BlockServiceWatcher,
BlockStdScope,
type EditorHost,
ShadowlessElement,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import {
Bound,
debounce,
deserializeXYWH,
DisposableGroup,
WithDisposable,
} from '@blocksuite/global/utils';
import { type Query, type Store } from '@blocksuite/store';
import { css, html, nothing, type PropertyValues } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootPreviewBlockComponent } from '../../edgeless-root-preview-block.js';
const DEFAULT_PREVIEW_CONTAINER_WIDTH = 280;
const DEFAULT_PREVIEW_CONTAINER_HEIGHT = 166;
const styles = css`
.frame-preview-container {
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
position: relative;
}
.frame-preview-surface-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
overflow: hidden;
}
.frame-preview-viewport {
max-width: 100%;
box-sizing: border-box;
margin: 0 auto;
position: relative;
overflow: hidden;
pointer-events: none;
user-select: none;
.edgeless-background {
background-color: transparent;
background-image: none;
}
}
`;
export class FramePreview extends WithDisposable(ShadowlessElement) {
static override styles = styles;
private readonly _clearFrameDisposables = () => {
this._frameDisposables?.dispose();
this._frameDisposables = null;
};
private readonly _docFilter: Query = {
mode: 'loose',
match: [
{
flavour: 'affine:frame',
viewType: 'hidden',
},
],
};
private _frameDisposables: DisposableGroup | null = null;
private _previewDoc: Store | null = null;
private readonly _previewSpec =
SpecProvider.getInstance().getSpec('preview:edgeless');
private readonly _updateFrameViewportWH = () => {
const [, , w, h] = deserializeXYWH(this.frame.xywh);
let scale = 1;
if (this.fillScreen) {
scale = Math.max(this.surfaceWidth / w, this.surfaceHeight / h);
} else {
scale = Math.min(this.surfaceWidth / w, this.surfaceHeight / h);
}
this.frameViewportWH = {
width: w * scale,
height: h * scale,
};
};
get _originalDoc() {
return this.frame.doc;
}
private _initPreviewDoc() {
this._previewDoc = this._originalDoc.workspace.getDoc(
this._originalDoc.id,
{
query: this._docFilter,
readonly: true,
}
);
this.disposables.add(() => {
this._originalDoc.doc.clearQuery(this._docFilter);
});
}
private _initSpec() {
const refreshViewport = this._refreshViewport.bind(this);
class FramePreviewWatcher extends BlockServiceWatcher {
static override readonly flavour = 'affine:page';
override mounted() {
const blockService = this.blockService;
blockService.disposables.add(
blockService.specSlots.viewConnected.on(({ component }) => {
const edgelessBlock =
component as EdgelessRootPreviewBlockComponent;
edgelessBlock.editorViewportSelector = 'frame-preview-viewport';
edgelessBlock.service.viewport.sizeUpdated.once(() => {
refreshViewport();
});
})
);
}
}
this._previewSpec.extend([FramePreviewWatcher]);
}
private _refreshViewport() {
const previewEditorHost = this.previewEditor;
if (!previewEditorHost) return;
const { viewport } = previewEditorHost.std.get(GfxControllerIdentifier);
const frameBound = Bound.deserialize(this.frame.xywh);
viewport.setViewportByBound(frameBound);
}
private _renderSurfaceContent() {
if (!this._previewDoc || !this.frame) return nothing;
const { width, height } = this.frameViewportWH;
const _previewSpec = this._previewSpec.value;
return html`<div
class="frame-preview-surface-container"
style=${styleMap({
width: `${this.surfaceWidth}px`,
height: `${this.surfaceHeight}px`,
})}
>
<div
class="frame-preview-viewport"
style=${styleMap({
width: `${width}px`,
height: `${height}px`,
})}
>
${new BlockStdScope({
store: this._previewDoc,
extensions: _previewSpec,
}).render()}
</div>
</div>`;
}
private _setFrameDisposables(frame: FrameBlockModel) {
this._clearFrameDisposables();
this._frameDisposables = new DisposableGroup();
this._frameDisposables.add(
frame.propsUpdated.on(debounce(this._updateFrameViewportWH, 10))
);
}
override connectedCallback() {
super.connectedCallback();
this._initSpec();
this._initPreviewDoc();
this._updateFrameViewportWH();
this._setFrameDisposables(this.frame);
}
override disconnectedCallback() {
super.disconnectedCallback();
this._clearFrameDisposables();
}
override render() {
const { frame } = this;
const noContent = !frame || !frame.xywh;
return html`<div class="frame-preview-container">
${noContent ? nothing : this._renderSurfaceContent()}
</div>`;
}
override updated(_changedProperties: PropertyValues) {
if (_changedProperties.has('frame')) {
this._setFrameDisposables(this.frame);
}
if (_changedProperties.has('frameViewportWH')) {
this._refreshViewport();
}
}
@state()
accessor fillScreen = false;
@property({ attribute: false })
accessor frame!: FrameBlockModel;
@state()
accessor frameViewportWH = {
width: 0,
height: 0,
};
@query('editor-host')
accessor previewEditor: EditorHost | null = null;
@property({ attribute: false })
accessor surfaceHeight: number = DEFAULT_PREVIEW_CONTAINER_HEIGHT;
@property({ attribute: false })
accessor surfaceWidth: number = DEFAULT_PREVIEW_CONTAINER_WIDTH;
}
declare global {
interface HTMLElementTagNameMap {
'frame-preview': FramePreview;
}
}
@@ -0,0 +1,445 @@
import type { NoteBlockComponent } from '@blocksuite/affine-block-note';
import { isNoteBlock } from '@blocksuite/affine-block-surface';
import {
DEFAULT_NOTE_HEIGHT,
type NoteBlockModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import { getRectByBlockComponent } from '@blocksuite/affine-shared/utils';
import { WidgetComponent } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import {
deserializeXYWH,
DisposableGroup,
Point,
serializeXYWH,
} from '@blocksuite/global/utils';
import { ScissorsIcon } from '@blocksuite/icons/lit';
import { css, html, nothing, type PropertyValues } from 'lit';
import { state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block';
const DIVIDING_LINE_OFFSET = 4;
const NEW_NOTE_GAP = 40;
const styles = css`
:host {
display: flex;
}
.note-slicer-container {
display: flex;
}
.note-slicer-button {
position: absolute;
top: 0;
right: 0;
display: flex;
box-sizing: border-box;
border-radius: 4px;
justify-content: center;
align-items: center;
color: var(--affine-icon-color);
border: 1px solid var(--affine-border-color);
background-color: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-menu-shadow);
cursor: pointer;
width: 24px;
height: 24px;
transform-origin: left top;
z-index: var(--affine-z-index-popover);
opacity: 0;
transition: opacity 150ms cubic-bezier(0.25, 0.1, 0.25, 1);
}
.note-slicer-dividing-line-container {
display: flex;
align-items: center;
position: absolute;
left: 0;
top: 0;
height: 4px;
cursor: pointer;
}
.note-slicer-dividing-line {
display: block;
height: 1px;
width: 100%;
z-index: var(--affine-z-index-popover);
background-image: linear-gradient(
to right,
var(--affine-black-10) 50%,
transparent 50%
);
background-size: 4px 100%;
}
.note-slicer-dividing-line-container.active .note-slicer-dividing-line {
background-image: linear-gradient(
to right,
var(--affine-black-60) 50%,
transparent 50%
);
animation: slide 0.3s linear infinite;
}
@keyframes slide {
0% {
background-position: 0 0;
}
100% {
background-position: -4px 0;
}
}
`;
export const NOTE_SLICER_WIDGET = 'note-slicer';
export class NoteSlicer extends WidgetComponent<
RootBlockModel,
EdgelessRootBlockComponent
> {
static override styles = styles;
private _divingLinePositions: Point[] = [];
private _hidden = false;
private _noteBlockIds: string[] = [];
private _noteDisposables: DisposableGroup | null = null;
get _editorHost() {
return this.std.host;
}
get _noteBlock() {
if (!this._editorHost) return null;
const noteBlock = this._editorHost.view.getBlock(
this._anchorNote?.id ?? ''
);
return noteBlock ? (noteBlock as NoteBlockComponent) : null;
}
get _selection() {
return this.gfx.selection;
}
get _viewportOffset() {
const { viewport } = this.gfx;
return {
left: viewport.left ?? 0,
top: viewport.top ?? 0,
};
}
get _zoom() {
return this.gfx.viewport.zoom;
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get selectedRectEle() {
return this.block.selectedRectWidget;
}
private _sliceNote() {
if (!this._anchorNote || !this._noteBlockIds.length) return;
const doc = this.doc;
const {
index: originIndex,
xywh,
background,
children,
displayMode,
} = this._anchorNote;
const {
collapse: _,
collapsedHeight: __,
...restOfEdgeless
} = this._anchorNote.edgeless;
const anchorBlockId = this._noteBlockIds[this._activeSlicerIndex];
if (!anchorBlockId) return;
const sliceIndex = children.findIndex(block => block.id === anchorBlockId);
const resetBlocks = children.slice(sliceIndex + 1);
const [x, , width] = deserializeXYWH(xywh);
const sliceVerticalPos =
this._divingLinePositions[this._activeSlicerIndex].y;
const newY = this.gfx.viewport.toModelCoord(x, sliceVerticalPos)[1];
const newNoteId = this.doc.addBlock(
'affine:note',
{
background,
displayMode,
xywh: serializeXYWH(x, newY + NEW_NOTE_GAP, width, DEFAULT_NOTE_HEIGHT),
index: originIndex + 1,
edgeless: restOfEdgeless,
},
doc.root?.id
);
doc.moveBlocks(resetBlocks, doc.getBlockById(newNoteId) as NoteBlockModel);
this._activeSlicerIndex = 0;
this._selection.set({
elements: [newNoteId],
editing: false,
});
this.std.getOptional(TelemetryProvider)?.track('SplitNote', {
control: 'NoteSlicer',
});
}
private _updateActiveSlicerIndex(pos: Point) {
const { _divingLinePositions } = this;
const curY = pos.y + DIVIDING_LINE_OFFSET * this._zoom;
let index = -1;
for (let i = 0; i < _divingLinePositions.length; i++) {
const currentY = _divingLinePositions[i].y;
const previousY = i > 0 ? _divingLinePositions[i - 1].y : 0;
const midY = (currentY + previousY) / 2;
if (curY < midY) {
break;
}
index++;
}
if (index < 0) index = 0;
this._activeSlicerIndex = index;
}
private _updateDivingLineAndBlockIds() {
if (!this._anchorNote || !this._noteBlock) {
this._divingLinePositions = [];
this._noteBlockIds = [];
return;
}
const divingLinePositions: Point[] = [];
const noteBlockIds: string[] = [];
const noteRect = this._noteBlock.getBoundingClientRect();
const noteTop = noteRect.top;
const noteBottom = noteRect.bottom;
for (let i = 0; i < this._anchorNote.children.length - 1; i++) {
const child = this._anchorNote.children[i];
const rect = this.host.view.getBlock(child.id)?.getBoundingClientRect();
if (rect && rect.bottom > noteTop && rect.bottom < noteBottom) {
const x = rect.x - this._viewportOffset.left;
const y =
rect.bottom +
DIVIDING_LINE_OFFSET * this._zoom -
this._viewportOffset.top;
divingLinePositions.push(new Point(x, y));
noteBlockIds.push(child.id);
}
}
this._divingLinePositions = divingLinePositions;
this._noteBlockIds = noteBlockIds;
}
private _updateSlicedNote() {
const { selectedElements } = this.gfx.selection;
if (
!this.gfx.selection.editing &&
selectedElements.length === 1 &&
isNoteBlock(selectedElements[0])
) {
this._anchorNote = selectedElements[0];
} else {
this._anchorNote = null;
}
}
override connectedCallback(): void {
super.connectedCallback();
const { disposables, std, block, gfx } = this;
this._updateDivingLineAndBlockIds();
disposables.add(
block.slots.elementResizeStart.on(() => {
this._isResizing = true;
})
);
disposables.add(
block.slots.elementResizeEnd.on(() => {
this._isResizing = false;
})
);
disposables.add(
std.event.add('pointerMove', ctx => {
if (this._hidden) this._hidden = false;
const state = ctx.get('pointerState');
const pos = new Point(state.x, state.y);
this._updateActiveSlicerIndex(pos);
})
);
disposables.add(
gfx.viewport.viewportUpdated.on(() => {
this._hidden = true;
this.requestUpdate();
})
);
disposables.add(
gfx.selection.slots.updated.on(() => {
this._enableNoteSlicer = false;
this._updateSlicedNote();
if (this.selectedRectEle) {
this.selectedRectEle.autoCompleteOff = false;
}
})
);
disposables.add(
block.slots.toggleNoteSlicer.on(() => {
this._enableNoteSlicer = !this._enableNoteSlicer;
if (this.selectedRectEle && this._enableNoteSlicer) {
this.selectedRectEle.autoCompleteOff = true;
}
})
);
const { surface } = block;
requestAnimationFrame(() => {
if (surface.isConnected && std.event) {
disposables.add(
std.event.add('click', ctx => {
const event = ctx.get('pointerState');
const { raw } = event;
const target = raw.target as HTMLElement;
if (!target) return;
if (target.closest('note-slicer')) {
this._sliceNote();
}
})
);
}
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.disposables.dispose();
this._noteDisposables?.dispose();
this._noteDisposables = null;
}
override firstUpdated() {
if (!this.block.service) return;
this.disposables.add(
this.block.service.uiEventDispatcher.add('wheel', () => {
this._hidden = true;
this.requestUpdate();
})
);
}
override render() {
if (
this.doc.readonly ||
this._hidden ||
this._isResizing ||
!this._anchorNote ||
!this._enableNoteSlicer
) {
return nothing;
}
this._updateDivingLineAndBlockIds();
const noteBlock = this._noteBlock;
if (!noteBlock || !this._divingLinePositions.length) return nothing;
const rect = getRectByBlockComponent(noteBlock);
const width = rect.width - 2 * EDGELESS_BLOCK_CHILD_PADDING * this._zoom;
const buttonPosition = this._divingLinePositions[this._activeSlicerIndex];
return html`<div class="note-slicer-container">
<div
class="note-slicer-button"
style=${styleMap({
left: `${buttonPosition.x - 66 * this._zoom}px`,
top: `${buttonPosition.y}px`,
opacity: 1,
transform: 'translateY(-50%)',
})}
>
${ScissorsIcon({ width: '16px', height: '16px' })}
</div>
${this._divingLinePositions.map((pos, idx) => {
const dividingLineClasses = classMap({
'note-slicer-dividing-line-container': true,
active: idx === this._activeSlicerIndex,
});
return html`<div
class=${dividingLineClasses}
style=${styleMap({
left: `${pos.x}px`,
top: `${pos.y}px`,
width: `${width}px`,
})}
>
<span class="note-slicer-dividing-line"></span>
</div>`;
})}
</div> `;
}
protected override updated(_changedProperties: PropertyValues) {
super.updated(_changedProperties);
if (_changedProperties.has('anchorNote')) {
this._noteDisposables?.dispose();
this._noteDisposables = null;
if (this._anchorNote) {
this._noteDisposables = new DisposableGroup();
this._noteDisposables.add(
this._anchorNote.propsUpdated.on(({ key }) => {
if (key === 'children' || key === 'xywh') {
this.requestUpdate();
}
})
);
}
}
}
@state()
private accessor _activeSlicerIndex = 0;
@state()
private accessor _anchorNote: NoteBlockModel | null = null;
@state()
private accessor _enableNoteSlicer = false;
@state()
private accessor _isResizing = false;
}
declare global {
interface HTMLElementTagNameMap {
'note-slicer': NoteSlicer;
}
}
@@ -0,0 +1,76 @@
import { TextAlign } from '@blocksuite/affine-model';
import {
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
} from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
const TEXT_ALIGN_LIST = [
{
name: 'Left',
value: TextAlign.Left,
icon: TextAlignLeftIcon(),
},
{
name: 'Center',
value: TextAlign.Center,
icon: TextAlignCenterIcon(),
},
{
name: 'Right',
value: TextAlign.Right,
icon: TextAlignRightIcon(),
},
];
export class EdgelessAlignPanel extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
`;
private _onSelect(value: TextAlign) {
this.value = value;
if (this.onSelect) {
this.onSelect(value);
}
}
override render() {
return repeat(
TEXT_ALIGN_LIST,
item => item.name,
({ name, value, icon }) => html`
<edgeless-tool-icon-button
.activeMode=${'background'}
aria-label=${name}
.tooltip=${name}
.active=${this.value === value}
.iconSize=${'20px'}
@click=${() => this._onSelect(value)}
>
${icon}
</edgeless-tool-icon-button>
`
);
}
@property({ attribute: false })
accessor onSelect: undefined | ((value: TextAlign) => void) = undefined;
@property({ attribute: false })
accessor value: TextAlign = TextAlign.Left;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-align-panel': EdgelessAlignPanel;
}
}
@@ -0,0 +1,71 @@
import type { EmbedCardStyle } from '@blocksuite/affine-model';
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
export class CardStylePanel extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
icon-button {
padding: var(--1, 0px);
justify-content: center;
}
icon-button.selected {
border: 1px solid var(--affine-brand-color);
}
`;
override render() {
const options = this.options;
if (!options?.length) return nothing;
return repeat(
options,
options => options.style,
({ style, Icon, tooltip }) => html`
<icon-button
width="76px"
height="76px"
class=${classMap({
selected: this.value === style,
})}
@click=${() => {
this.onSelect(style);
this.value = style;
}}
>
${Icon}
<affine-tooltip .offset=${4}>${tooltip}</affine-tooltip>
</icon-button>
`
);
}
@property({ attribute: false })
accessor onSelect!: (value: EmbedCardStyle) => void;
@property({ attribute: false })
accessor options!: {
style: EmbedCardStyle;
Icon: TemplateResult<1>;
tooltip: string;
}[];
@property({ attribute: false })
accessor value: EmbedCardStyle | undefined = undefined;
}
declare global {
interface HTMLElementTagNameMap {
'card-style-panel': CardStylePanel;
}
}
@@ -0,0 +1,318 @@
import type { Color, ColorScheme, Palette } from '@blocksuite/affine-model';
import { isTransparent, resolveColor } from '@blocksuite/affine-model';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ColorEvent } from '@blocksuite/affine-shared/utils';
import { css, html, LitElement, nothing, svg, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { repeat } from 'lit/directives/repeat.js';
import { isEqual } from 'lodash-es';
function TransparentIcon(hollowCircle = false) {
const CircleIcon: TemplateResult | typeof nothing = hollowCircle
? svg`<circle cx="10" cy="10" r="8" fill="white" />`
: nothing;
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M-1.17405 5.17857C-1.2241 5.5285 -1.25 5.88623 -1.25 6.25V8.39286H1.96429V11.6071H-1.25V13.75C-1.25 14.1138 -1.2241 14.4715 -1.17405 14.8214H1.96429V18.0357H0.0943102C0.602244 18.7639 1.23609 19.3978 1.96429 19.9057V18.0357L5.17857 18.0357V21.174C5.5285 21.2241 5.88623 21.25 6.25 21.25H8.39286V18.0357H11.6071V21.25H13.75C14.1138 21.25 14.4715 21.2241 14.8214 21.174V18.0357H18.0357L18.0357 19.9057C18.7639 19.3978 19.3978 18.7639 19.9057 18.0357L18.0357 18.0357V14.8214H21.174C21.2241 14.4715 21.25 14.1138 21.25 13.75V11.6071H18.0357V8.39286H21.25V6.25C21.25 5.88623 21.2241 5.5285 21.174 5.17857H18.0357V1.96429H19.9057C19.3978 1.23609 18.7639 0.602244 18.0357 0.09431L18.0357 1.96429H14.8214V-1.17405C14.4715 -1.2241 14.1138 -1.25 13.75 -1.25H11.6071V1.96429H8.39286V-1.25H6.25C5.88623 -1.25 5.5285 -1.2241 5.17857 -1.17405V1.96429H1.96429V0.0943099C1.23609 0.602244 0.602244 1.23609 0.0943099 1.96429H1.96429V5.17857H-1.17405ZM5.17857 5.17857V1.96429H8.39286V5.17857H5.17857ZM5.17857 8.39286H1.96429V5.17857H5.17857V8.39286ZM8.39286 8.39286V5.17857H11.6071V8.39286H8.39286ZM8.39286 11.6071V8.39286H5.17857V11.6071H1.96429V14.8214H5.17857V18.0357H8.39286V14.8214H11.6071V18.0357H14.8214V14.8214H18.0357V11.6071H14.8214V8.39286H18.0357V5.17857H14.8214V1.96429H11.6071V5.17857H14.8214V8.39286H11.6071V11.6071H8.39286ZM8.39286 11.6071V14.8214H5.17857V11.6071H8.39286ZM11.6071 11.6071H14.8214V14.8214H11.6071V11.6071Z"
fill="#D9D9D9"
/>
${CircleIcon}
</svg>
`;
}
function CircleIcon(color: string) {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="${color}"
>
<circle cx="10" cy="10" r="10" />
</svg>
`;
}
function HollowCircleIcon(color: string) {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="${color}"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10 17C13.866 17 17 13.866 17 10C17 6.13401 13.866 3 10 3C6.13401 3 3 6.13401 3 10C3 13.866 6.13401 17 10 17ZM10 20C15.5228 20 20 15.5228 20 10C20 4.47715 15.5228 0 10 0C4.47715 0 0 4.47715 0 10C0 15.5228 4.47715 20 10 20Z"
/>
</svg>
`;
}
function AdditionIcon(color: string, hollowCircle: boolean) {
if (isTransparent(color)) {
return TransparentIcon(hollowCircle);
}
if (hollowCircle) {
return HollowCircleIcon(color);
}
return CircleIcon(color);
}
export class EdgelessColorButton extends LitElement {
static override styles = css`
:host {
position: relative;
width: 20px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.color-unit {
position: relative;
width: 16px;
height: 16px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
box-sizing: border-box;
}
.color-unit svg {
width: 100%;
height: 100%;
border-radius: 50%;
overflow: hidden;
}
:host .color-unit::after {
position: absolute;
display: block;
content: '';
width: 100%;
height: 100%;
border-radius: 50%;
box-sizing: border-box;
overflow: hidden;
border-width: 0.5px;
border-style: solid;
border-color: ${unsafeCSSVarV2('layer/insideBorder/blackBorder')};
}
:host(.black) .color-unit:after {
border-color: ${unsafeCSSVarV2('layer/insideBorder/border')};
}
:host(.large) {
width: 24px;
height: 24px;
}
:host(.large) .color-unit {
width: 20px;
height: 20px;
}
:host::after {
position: absolute;
display: block;
content: '';
width: 27px;
height: 27px;
border-radius: 50%;
box-sizing: border-box;
overflow: hidden;
pointer-events: none;
}
:host([active])::after {
border: 1.5px solid var(--affine-primary-color);
}
`;
get preprocessColor() {
const value = resolveColor(this.color, this.theme);
return value.startsWith('--') ? `var(${value})` : value;
}
override render() {
const { label, preprocessColor, hollowCircle } = this;
const additionIcon = AdditionIcon(preprocessColor, !!hollowCircle);
return html`<div class="color-unit" aria-label=${ifDefined(label)}>
${additionIcon}
</div>`;
}
@property({ attribute: true, type: Boolean })
accessor active: boolean = false;
@property({ attribute: false })
accessor color!: Color;
@property({ attribute: false })
accessor hollowCircle: boolean = false;
@property({ attribute: false })
accessor label: string | undefined = undefined;
@property({ attribute: false })
accessor theme!: ColorScheme;
}
export class EdgelessColorPanel extends LitElement {
static override styles = css`
:host {
display: grid;
grid-gap: 4px;
grid-template-columns: repeat(9, 1fr);
}
/* note */
:host(.small) {
grid-template-columns: repeat(6, 1fr);
grid-gap: 8px;
}
/* edgeless toolbar */
:host(.one-way) {
display: flex;
flex-wrap: nowrap;
padding: 0 2px;
gap: 14px;
box-sizing: border-box;
background: var(--affine-background-overlay-panel-color);
}
`;
onSelect(palette: Palette) {
this.dispatchEvent(
new ColorEvent('select', {
detail: palette,
composed: true,
bubbles: true,
})
);
}
get resolvedValue() {
return this.value && resolveColor(this.value, this.theme);
}
override render() {
const resolvedValue = this.resolvedValue;
return html`
${repeat(
this.palettes,
palette => palette.key,
palette => {
const resolvedColor = resolveColor(palette.value, this.theme);
const activated = isEqual(resolvedColor, resolvedValue);
return html`<edgeless-color-button
class=${classMap({ large: true })}
.label=${palette.key}
.color=${palette.value}
.theme=${this.theme}
.hollowCircle=${this.hollowCircle}
?active=${activated}
@click=${() => {
this.onSelect(palette);
this.value = resolvedColor;
}}
>
</edgeless-color-button>`;
}
)}
<slot name="custom"></slot>
`;
}
@property({ attribute: false })
accessor hasTransparent: boolean = true;
@property({ attribute: false })
accessor hollowCircle = false;
@property()
accessor openColorPicker!: (e: MouseEvent) => void;
@property({ type: Array })
accessor palettes: readonly Palette[] = [];
@property({ attribute: false })
accessor theme!: ColorScheme;
@property({ attribute: false })
accessor value: Color | null = null;
}
export class EdgelessTextColorIcon extends LitElement {
static override styles = css`
:host {
display: flex;
justify-content: center;
align-items: center;
width: 20px;
height: 20px;
}
`;
get preprocessColor() {
const color = this.color;
return color.startsWith('--') ? `var(${color})` : color;
}
override render() {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fill="currentColor"
d="M8.71093 3.85123C8.91241 3.31395 9.42603 2.95801 9.99984 2.95801C10.5737 2.95801 11.0873 3.31395 11.2888 3.85123L14.7517 13.0858C14.8729 13.409 14.7092 13.7692 14.386 13.8904C14.0628 14.0116 13.7025 13.8479 13.5813 13.5247L12.5648 10.8141H7.43487L6.41838 13.5247C6.29718 13.8479 5.93693 14.0116 5.61373 13.8904C5.29052 13.7692 5.12677 13.409 5.24797 13.0858L8.71093 3.85123ZM7.90362 9.56405H12.0961L10.1183 4.29013C10.0998 4.24073 10.0526 4.20801 9.99984 4.20801C9.94709 4.20801 9.89986 4.24073 9.88134 4.29013L7.90362 9.56405Z"
/>
<rect
x="3.3335"
y="15"
width="13.3333"
height="2.08333"
rx="1"
fill=${this.preprocessColor}
/>
</svg>
`;
}
@property({ attribute: false })
accessor color!: string;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-color-panel': EdgelessColorPanel;
'edgeless-color-button': EdgelessColorButton;
'edgeless-text-color-icon': EdgelessTextColorIcon;
}
}
@@ -0,0 +1,63 @@
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;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-font-family-panel': EdgelessFontFamilyPanel;
}
}
@@ -0,0 +1,169 @@
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;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-font-weight-and-style-panel': EdgelessFontWeightAndStylePanel;
}
}
@@ -0,0 +1,84 @@
import { html } from 'lit';
export const NoteNoShadowIcon = html`
<svg
width="60"
height="72"
viewBox="0 0 60 72"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="60" height="72" />
<rect
x="0.5"
y="0.5"
width="58.0769"
height="71"
stroke="black"
stroke-opacity="0.1"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M21.9576 26.8962L38.6423 43.5809C42.5269 38.9268 42.2845 31.993 37.9149 27.6235C33.5454 23.254 26.6117 23.0115 21.9576 26.8962ZM37.1193 45.1038L20.4346 28.4192C16.55 33.0732 16.7924 40.007 21.162 44.3765C25.5315 48.746 32.4652 48.9885 37.1193 45.1038ZM19.639 26.1005C25.1063 20.6332 33.9706 20.6332 39.4379 26.1005C44.9053 31.5678 44.9053 40.4322 39.4379 45.8995C33.9706 51.3668 25.1063 51.3668 19.639 45.8995C14.1716 40.4322 14.1716 31.5678 19.639 26.1005Z"
fill="black"
fill-opacity="0.1"
/>
</svg>
`;
export const NoteShadowSampleIcon = html`
<svg
width="60"
height="72"
viewBox="0 0 60 72"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="60" height="72" />
<rect
x="9.23071"
y="12.0771"
width="32.3077"
height="4.61538"
rx="2"
fill="black"
fill-opacity="0.1"
/>
<rect
x="9.23071"
y="25.8462"
width="40.6154"
height="2.76923"
rx="1.38462"
fill="black"
fill-opacity="0.1"
/>
<rect
x="9.23071"
y="35.6152"
width="40.6154"
height="2.76923"
rx="1.38462"
fill="black"
fill-opacity="0.1"
/>
<rect
x="9.23071"
y="45.3843"
width="40.6154"
height="2.76923"
rx="1.38462"
fill="black"
fill-opacity="0.1"
/>
<rect
x="9.23071"
y="55.1533"
width="13.8462"
height="2.76923"
rx="1.38462"
fill="black"
fill-opacity="0.1"
/>
</svg>
`;
@@ -0,0 +1,98 @@
import { LineWidth, StrokeStyle } from '@blocksuite/affine-model';
import { BanIcon, DashLineIcon, StraightLineIcon } from '@blocksuite/icons/lit';
import { html } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import type { LineWidthEvent } from './line-width-panel.js';
export type LineStyleEvent =
| {
type: 'size';
value: LineWidth;
}
| {
type: 'lineStyle';
value: StrokeStyle;
};
interface LineStylesPanelProps {
onClick?: (e: LineStyleEvent) => void;
selectedLineSize?: LineWidth;
selectedLineStyle?: StrokeStyle;
lineStyles?: StrokeStyle[];
}
const LINE_STYLE_LIST = [
{
name: 'Solid',
value: StrokeStyle.Solid,
icon: StraightLineIcon(),
},
{
name: 'Dash',
value: StrokeStyle.Dash,
icon: DashLineIcon(),
},
{
name: 'None',
value: StrokeStyle.None,
icon: BanIcon(),
},
];
export function LineStylesPanel({
onClick,
selectedLineStyle,
selectedLineSize = LineWidth.Two,
lineStyles = [StrokeStyle.Solid, StrokeStyle.Dash, StrokeStyle.None],
}: LineStylesPanelProps = {}) {
const lineSizePanel = html`
<edgeless-line-width-panel
?disabled=${selectedLineStyle === StrokeStyle.None}
.selectedSize=${selectedLineSize}
@select=${(e: LineWidthEvent) => {
onClick?.({
type: 'size',
value: e.detail,
});
}}
></edgeless-line-width-panel>
`;
const lineStyleButtons = repeat(
LINE_STYLE_LIST.filter(item => lineStyles.includes(item.value)),
item => item.value,
({ name, icon, value }) => {
const active = selectedLineStyle === value;
const classInfo = {
'line-style-button': true,
[`mode-${value}`]: true,
};
if (active) classInfo['active'] = true;
return html`
<edgeless-tool-icon-button
class=${classMap(classInfo)}
.active=${active}
.activeMode=${'background'}
.tooltip=${name}
.iconSize=${'24px'}
@click=${() =>
onClick?.({
type: 'lineStyle',
value,
})}
>
${icon}
</edgeless-tool-icon-button>
`;
}
);
return html`
${lineSizePanel}
<editor-toolbar-separator></editor-toolbar-separator>
${lineStyleButtons}
`;
}
@@ -0,0 +1,258 @@
import { LINE_WIDTHS, LineWidth } from '@blocksuite/affine-model';
import { clamp, on, once } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement, nothing, type PropertyValues } from 'lit';
import { property, query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
interface Config {
width: number;
itemSize: number;
itemIconSize: number;
dragHandleSize: number;
count: number;
}
export class LineWidthEvent extends Event {
detail: LineWidth;
constructor(
type: string,
{
detail,
composed,
bubbles,
}: { detail: LineWidth; composed: boolean; bubbles: boolean }
) {
super(type, { bubbles, composed });
this.detail = detail;
}
}
export class EdgelessLineWidthPanel extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
--width: 140px;
--item-size: 16px;
--item-icon-size: 8px;
--drag-handle-size: 14px;
--cursor: 0;
--count: 6;
/* (16 - 14) / 2 + (cursor / (count - 1)) * (140 - 16) */
--drag-handle-center-x: calc(
(var(--item-size) - var(--drag-handle-size)) / 2 +
(var(--cursor) / (var(--count) - 1)) *
(var(--width) - var(--item-size))
);
}
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
.line-width-panel {
width: var(--width);
height: 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
position: relative;
cursor: default;
}
.line-width-button {
display: flex;
align-items: center;
justify-content: center;
width: var(--item-size);
height: var(--item-size);
z-index: 2;
}
.line-width-icon {
width: var(--item-icon-size);
height: var(--item-icon-size);
background-color: var(--affine-border-color);
border-radius: 50%;
}
.line-width-button[data-selected] .line-width-icon {
background-color: var(--affine-icon-color);
}
.drag-handle {
position: absolute;
width: var(--drag-handle-size);
height: var(--drag-handle-size);
border-radius: 50%;
background-color: var(--affine-icon-color);
z-index: 3;
transform: translateX(var(--drag-handle-center-x));
}
.bottom-line,
.line-width-overlay {
position: absolute;
height: 1px;
left: calc(var(--item-size) / 2);
}
.bottom-line {
width: calc(100% - var(--item-size));
background-color: var(--affine-border-color);
}
.line-width-overlay {
background-color: var(--affine-icon-color);
z-index: 1;
width: var(--drag-handle-center-x);
}
`;
private readonly _getDragHandlePosition = (e: PointerEvent) => {
return clamp(e.offsetX, 0, this.config.width);
};
private readonly _onPointerDown = (e: PointerEvent) => {
e.preventDefault();
this._onPointerMove(e);
const dispose = on(this, 'pointermove', this._onPointerMove);
this._disposables.add(once(this, 'pointerup', dispose));
this._disposables.add(once(this, 'pointerout', dispose));
};
private readonly _onPointerMove = (e: PointerEvent) => {
e.preventDefault();
const x = this._getDragHandlePosition(e);
this._updateLineWidthPanelByDragHandlePosition(x);
};
private _onSelect(lineWidth: LineWidth) {
// If the selected size is the same as the previous one, do nothing.
if (lineWidth === this.selectedSize) return;
this.dispatchEvent(
new LineWidthEvent('select', {
detail: lineWidth,
composed: true,
bubbles: true,
})
);
this.selectedSize = lineWidth;
}
private _updateLineWidthPanel(selectedSize: LineWidth) {
if (!this._lineWidthOverlay) return;
const index = this.lineWidths.findIndex(w => w === selectedSize);
if (index === -1) return;
this.style.setProperty('--cursor', `${index}`);
}
private _updateLineWidthPanelByDragHandlePosition(x: number) {
// Calculate the selected size based on the drag handle position.
// Need to select the nearest size.
const {
config: { width, itemSize, count },
lineWidths,
} = this;
const targetWidth = width - itemSize;
const halfItemSize = itemSize / 2;
const offsetX = halfItemSize + (width - itemSize * count) / (count - 1) / 2;
const selectedSize = lineWidths.findLast((_, n) => {
const cx = halfItemSize + (n / (count - 1)) * targetWidth;
return x >= cx - offsetX && x < cx + offsetX;
});
if (!selectedSize) return;
this._updateLineWidthPanel(selectedSize);
this._onSelect(selectedSize);
}
override connectedCallback() {
super.connectedCallback();
const {
style,
config: { width, itemSize, itemIconSize, dragHandleSize, count },
} = this;
style.setProperty('--width', `${width}px`);
style.setProperty('--item-size', `${itemSize}px`);
style.setProperty('--item-icon-size', `${itemIconSize}px`);
style.setProperty('--drag-handle-size', `${dragHandleSize}px`);
style.setProperty('--count', `${count}`);
}
override firstUpdated() {
this._updateLineWidthPanel(this.selectedSize);
this._disposables.addFromEvent(this, 'pointerdown', this._onPointerDown);
}
override render() {
return html`<div class="line-width-panel">
${repeat(
this.lineWidths,
w => w,
(w, n) =>
html`<div
class="line-width-button"
aria-label=${w}
data-index=${n}
?data-selected=${w <= this.selectedSize}
>
<div class="line-width-icon"></div>
</div>`
)}
<div class="drag-handle"></div>
<div class="bottom-line"></div>
<div class="line-width-overlay"></div>
${this.hasTooltip
? html`<affine-tooltip .offset=${8}>Thickness</affine-tooltip>`
: nothing}
</div>`;
}
override willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has('selectedSize')) {
this._updateLineWidthPanel(this.selectedSize);
}
}
@query('.line-width-overlay')
private accessor _lineWidthOverlay!: HTMLElement;
accessor config: Config = {
width: 140,
itemSize: 16,
itemIconSize: 8,
dragHandleSize: 14,
count: LINE_WIDTHS.length,
};
@property({ attribute: false, type: Boolean })
accessor disabled = false;
@property({ attribute: false })
accessor hasTooltip = true;
@property({ attribute: false })
accessor lineWidths: LineWidth[] = LINE_WIDTHS;
@property({ attribute: false })
accessor selectedSize: LineWidth = LineWidth.Two;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-line-width-panel': EdgelessLineWidthPanel;
}
}
@@ -0,0 +1,110 @@
import { NoteDisplayMode } from '@blocksuite/affine-model';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/utils';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
export class NoteDisplayModePanel extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 180px;
width: var(--panel-width);
gap: 4px;
}
.item {
display: flex;
align-items: center;
width: 100%;
height: 30px;
padding: 4px 12px;
border-radius: 4px;
gap: 4px;
box-sizing: border-box;
cursor: pointer;
}
.item-label {
flex: 1 1 0;
}
.item-icon {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
color: var(--affine-icon-color);
svg {
width: 20px;
height: 20px;
}
}
.item:hover,
.item.selected {
background-color: var(--affine-hover-color);
}
`;
private _DisplayModeIcon(mode: NoteDisplayMode) {
switch (mode) {
case NoteDisplayMode.DocAndEdgeless:
return html`${PageIcon()} ${EdgelessIcon()}`;
case NoteDisplayMode.DocOnly:
return html`${PageIcon()}`;
case NoteDisplayMode.EdgelessOnly:
return html`${EdgelessIcon()}`;
}
}
private _DisplayModeLabel(mode: NoteDisplayMode) {
switch (mode) {
case NoteDisplayMode.DocAndEdgeless:
return 'In Both';
case NoteDisplayMode.DocOnly:
return 'In Page Only';
case NoteDisplayMode.EdgelessOnly:
return 'In Edgeless Only';
}
}
override render() {
this.style.setProperty('--panel-width', `${this.panelWidth}px`);
return repeat(
Object.keys(NoteDisplayMode),
mode => mode,
mode => {
const displayMode =
NoteDisplayMode[mode as keyof typeof NoteDisplayMode];
const isSelected = displayMode === this.displayMode;
return html`<div
class="item ${isSelected ? 'selected' : ''} ${displayMode}"
@click=${() => this.onSelect(displayMode)}
@dblclick=${stopPropagation}
@pointerdown=${stopPropagation}
>
<div class="item-label">${this._DisplayModeLabel(displayMode)}</div>
<div class="item-icon">${this._DisplayModeIcon(displayMode)}</div>
</div>`;
}
);
}
@property({ attribute: false })
accessor displayMode!: NoteDisplayMode;
@property({ attribute: false })
accessor onSelect!: (displayMode: NoteDisplayMode) => void;
@property({ attribute: false })
accessor panelWidth = 240;
}
declare global {
interface HTMLElementTagNameMap {
'note-display-mode-panel': NoteDisplayModePanel;
}
}
@@ -0,0 +1,150 @@
import { ColorScheme, NoteShadow } from '@blocksuite/affine-model';
import { WithDisposable } from '@blocksuite/global/utils';
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';
import { NoteNoShadowIcon, NoteShadowSampleIcon } from './icons';
const SHADOWS = [
{
type: NoteShadow.None,
styles: {
light: '',
dark: '',
},
tooltip: 'No shadow',
},
{
type: NoteShadow.Box,
styles: {
light:
'0px 0.2px 4.8px 0px rgba(66, 65, 73, 0.2), 0px 0px 1.6px 0px rgba(66, 65, 73, 0.2)',
dark: '0px 0.2px 6px 0px rgba(0, 0, 0, 0.44), 0px 0px 2px 0px rgba(0, 0, 0, 0.66)',
},
tooltip: 'Box shadow',
},
{
type: NoteShadow.Sticker,
styles: {
light:
'0px 9.6px 10.4px -4px rgba(66, 65, 73, 0.07), 0px 10.4px 7.2px -8px rgba(66, 65, 73, 0.22)',
dark: '0px 9.6px 10.4px -4px rgba(0, 0, 0, 0.66), 0px 10.4px 7.2px -8px rgba(0, 0, 0, 0.44)',
},
tooltip: 'Sticker shadow',
},
{
type: NoteShadow.Paper,
styles: {
light:
'0px 0px 0px 4px rgba(255, 255, 255, 1), 0px 1.2px 2.4px 4.8px rgba(66, 65, 73, 0.16)',
dark: '0px 1.2px 2.4px 4.8px rgba(0, 0, 0, 0.36), 0px 0px 0px 3.4px rgba(75, 75, 75, 1)',
},
tooltip: 'Paper shadow',
},
{
type: NoteShadow.Float,
styles: {
light:
'0px 5.2px 12px 0px rgba(66, 65, 73, 0.13), 0px 0px 0.4px 1px rgba(0, 0, 0, 0.06)',
dark: '0px 5.2px 12px 0px rgba(0, 0, 0, 0.66), 0px 0px 0.4px 1px rgba(0, 0, 0, 0.44)',
},
tooltip: 'Floation shadow',
},
{
type: NoteShadow.Film,
styles: {
light:
'0px 0px 0px 1.4px rgba(0, 0, 0, 1), 2.4px 2.4px 0px 1px rgba(0, 0, 0, 1)',
dark: '0px 0px 0px 1.4px rgba(178, 178, 178, 1), 2.4px 2.4px 0px 1px rgba(178, 178, 178, 1)',
},
tooltip: 'Film shadow',
},
];
export class EdgelessNoteShadowPanel extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.item {
padding: 8px;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.item-icon {
display: flex;
justify-content: center;
align-items: center;
}
.item:hover {
background-color: var(--affine-hover-color);
}
`;
override render() {
return repeat(
SHADOWS,
shadow => shadow,
(shadow, index) =>
html`<style>
.item-icon svg rect:first-of-type {
fill: ${this.background.startsWith('--')
? `var(${this.background})`
: this.background};
}
</style>
<div
class="item"
@click=${() => this.onSelect(shadow.type)}
style=${styleMap({
border:
this.value === shadow.type
? '1px solid var(--affine-brand-color)'
: 'none',
})}
>
<edgeless-tool-icon-button
class="item-icon"
data-testid=${shadow.type.replace('--', '')}
.tooltip=${shadow.tooltip}
.tipPosition=${'bottom'}
.iconContainerPadding=${0}
style=${styleMap({
boxShadow: `${this.theme === ColorScheme.Dark ? shadow.styles.dark : shadow.styles.light}`,
})}
>
${index === 0 ? NoteNoShadowIcon : NoteShadowSampleIcon}
</edgeless-tool-icon-button>
</div>`
);
}
@property({ attribute: false })
accessor background!: string;
@property({ attribute: false })
accessor onSelect!: (value: string) => void;
@property({ attribute: false })
accessor theme!: ColorScheme;
@property({ attribute: false })
accessor value!: string;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-note-shadow-panel': EdgelessNoteShadowPanel;
}
}
@@ -0,0 +1,136 @@
import { clamp, stopPropagation } from '@blocksuite/affine-shared/utils';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
const MIN_SCALE = 0;
const MAX_SCALE = 400;
const SCALE_LIST = [50, 100, 200] as const;
function format(scale: number) {
return `${scale}%`;
}
export class EdgelessScalePanel extends LitElement {
static override styles = css`
:host {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
width: 68px;
}
edgeless-tool-icon-button {
align-self: stretch;
}
.scale-input {
display: flx;
align-self: stretch;
border: 0.5px solid var(--affine-border-color);
border-radius: 8px;
padding: 4px 8px;
box-sizing: border-box;
}
.scale-input::placeholder {
color: var(--affine-placeholder-color);
}
.scale-input:focus {
outline-color: var(--affine-primary-color);
outline-width: 0.5px;
}
`;
private readonly _onKeydown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault();
const input = e.target as HTMLInputElement;
const scale = parseInt(input.value.trim());
// Handle edge case where user enters a non-number
if (isNaN(scale)) {
input.value = '';
return;
}
// Handle edge case when user enters a number that is out of range
this._onSelect(clamp(scale, this.minScale, this.maxScale));
input.value = '';
this._onPopperClose();
}
};
private _onPopperClose() {
this.onPopperCose?.();
}
private _onSelect(scale: number) {
this.onSelect?.(scale / 100);
}
override render() {
return html`
${repeat(
this.scaleList,
scale => scale,
scale => {
const classes = `scale-${scale}`;
return html`<edgeless-tool-icon-button
class=${classes}
.iconContainerPadding=${[4, 8]}
.activeMode=${'background'}
.active=${this.scale === scale}
@click=${() => this._onSelect(scale)}
>
${format(scale)}
</edgeless-tool-icon-button>`;
}
)}
<input
class="scale-input"
type="text"
inputmode="numeric"
pattern="[0-9]*"
min="0"
placeholder=${format(Math.trunc(this.scale))}
@keydown=${this._onKeydown}
@input=${stopPropagation}
@click=${stopPropagation}
@pointerdown=${stopPropagation}
@cut=${stopPropagation}
@copy=${stopPropagation}
@paste=${stopPropagation}
/>
`;
}
@property({ attribute: false })
accessor maxScale: number = MAX_SCALE;
@property({ attribute: false })
accessor minScale: number = MIN_SCALE;
@property({ attribute: false })
accessor onPopperCose: (() => void) | undefined = undefined;
@property({ attribute: false })
accessor onSelect: ((size: number) => void) | undefined = undefined;
@property({ attribute: false })
accessor scale!: number;
@property({ attribute: false })
accessor scaleList: readonly number[] = SCALE_LIST;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-scale-panel': EdgelessScalePanel;
}
}
@@ -0,0 +1,71 @@
import { ShapeStyle } from '@blocksuite/affine-model';
import { Slot } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { ShapeTool } from '../../gfx-tool/shape-tool.js';
import { ShapeComponentConfig } from '../toolbar/shape/shape-menu-config.js';
export class EdgelessShapePanel extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
`;
slots = {
select: new Slot<ShapeTool['activatedOption']['shapeName']>(),
};
private _onSelect(value: ShapeTool['activatedOption']['shapeName']) {
this.selectedShape = value;
this.slots.select.emit(value);
}
override disconnectedCallback(): void {
this.slots.select.dispose();
super.disconnectedCallback();
}
override render() {
return repeat(
ShapeComponentConfig,
item => item.name,
({ name, generalIcon, scribbledIcon, tooltip, disabled }) =>
html`<edgeless-tool-icon-button
.disabled=${disabled}
.tooltip=${tooltip}
.active=${this.selectedShape === name}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${() => {
if (disabled) return;
this._onSelect(name);
}}
>
${this.shapeStyle === ShapeStyle.General
? generalIcon
: scribbledIcon}
</edgeless-tool-icon-button>`
);
}
@property({ attribute: false })
accessor selectedShape:
| ShapeTool['activatedOption']['shapeName']
| null
| undefined = undefined;
@property({ attribute: false })
accessor shapeStyle: ShapeStyle = ShapeStyle.Scribbled;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-shape-panel': EdgelessShapePanel;
}
}
@@ -0,0 +1,65 @@
import { ShapeStyle } from '@blocksuite/affine-model';
import { StyleGeneralIcon, StyleScribbleIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
const SHAPE_STYLE_LIST = [
{
value: ShapeStyle.General,
icon: StyleGeneralIcon(),
},
{
value: ShapeStyle.Scribbled,
icon: StyleScribbleIcon(),
},
];
export class EdgelessShapeStylePanel extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
`;
private _onSelect(value: ShapeStyle) {
this.value = value;
if (this.onSelect) {
this.onSelect(value);
}
}
override render() {
return repeat(
SHAPE_STYLE_LIST,
item => item.value,
({ value, icon }) =>
html`<edgeless-tool-icon-button
.tipPosition=${'top'}
.activeMode=${'background'}
aria-label=${value}
.tooltip=${value}
.active=${this.value === value}
.iconSize=${'20px'}
@click=${() => this._onSelect(value)}
>
${icon}
</edgeless-tool-icon-button>`
);
}
@property({ attribute: false })
accessor onSelect: undefined | ((value: ShapeStyle) => void) = undefined;
@property({ attribute: false })
accessor value!: ShapeStyle;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-shape-style-panel': EdgelessShapeStylePanel;
}
}
@@ -0,0 +1,167 @@
import { clamp, stopPropagation } from '@blocksuite/affine-shared/utils';
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';
const MIN_SIZE = 1;
const MAX_SIZE = 200;
type SizeItem = {
name?: string;
value: number;
};
export class EdgelessSizePanel extends LitElement {
static override styles = css`
:host {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
width: 68px;
}
edgeless-tool-icon-button {
align-self: stretch;
}
.size-input {
display: flex;
align-self: stretch;
width: 100%;
border: 0.5px solid var(--affine-border-color);
border-radius: 8px;
padding: 4px 8px;
box-sizing: border-box;
}
.size-input::placeholder {
color: var(--affine-placeholder-color);
}
.size-input:focus {
outline-color: var(--affine-primary-color);
outline-width: 0.5px;
}
:host([data-type='check']) {
gap: 0;
}
:host([data-type='check']) .size-input {
margin-top: 4px;
}
`;
private readonly _onKeydown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault();
const input = e.target as HTMLInputElement;
const size = parseInt(input.value.trim());
// Handle edge case where user enters a non-number
if (isNaN(size)) {
input.value = '';
return;
}
// Handle edge case when user enters a number that is out of range
this._onSelect(clamp(size, this.minSize, this.maxSize));
input.value = '';
this._onPopperClose();
}
};
renderItemWithCheck = ({ name, value }: SizeItem) => {
const active = this.size === value;
return html`
<edgeless-tool-icon-button
.iconContainerPadding=${[4, 8]}
.justify=${'space-between'}
.active=${active}
.iconSize=${'20px'}
@click=${() => this._onSelect(value)}
>
${name ?? value} ${active ? DoneIcon() : nothing}
</edgeless-tool-icon-button>
`;
};
renderItemWithNormal = ({ name, value }: SizeItem) => {
return html`
<edgeless-tool-icon-button
.iconContainerPadding=${[4, 8]}
.active=${this.size === value}
.activeMode=${'background'}
@click=${() => this._onSelect(value)}
>
${name ?? value}
</edgeless-tool-icon-button>
`;
};
private _onPopperClose() {
this.onPopperCose?.();
}
private _onSelect(size: number) {
this.onSelect?.(size);
}
override render() {
return html`
${repeat(this.sizeList, sizeItem => sizeItem.name, this.renderItem())}
<input
class="size-input"
type="text"
inputmode="numeric"
pattern="[0-9]*"
min="0"
placeholder=${Math.trunc(this.size)}
@keydown=${this._onKeydown}
@input=${stopPropagation}
@click=${stopPropagation}
@pointerdown=${stopPropagation}
@cut=${stopPropagation}
@copy=${stopPropagation}
@paste=${stopPropagation}
/>
`;
}
renderItem() {
return this.type === 'normal'
? this.renderItemWithNormal
: this.renderItemWithCheck;
}
@property({ attribute: false })
accessor maxSize: number = MAX_SIZE;
@property({ attribute: false })
accessor minSize: number = MIN_SIZE;
@property({ attribute: false })
accessor onPopperCose: (() => void) | undefined = undefined;
@property({ attribute: false })
accessor onSelect: ((size: number) => void) | undefined = undefined;
@property({ attribute: false })
accessor size!: number;
@property({ attribute: false })
accessor sizeList!: SizeItem[];
@property({ attribute: 'data-type' })
accessor type: 'normal' | 'check' = 'normal';
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-size-panel': EdgelessSizePanel;
}
}
@@ -0,0 +1,81 @@
import {
type ColorScheme,
DefaultTheme,
type StrokeStyle,
} from '@blocksuite/affine-model';
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { type LineStyleEvent, LineStylesPanel } from './line-styles-panel.js';
export class StrokeStylePanel extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.line-styles {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
`;
override render() {
return html`
<div class="line-styles">
${LineStylesPanel({
selectedLineSize: this.strokeWidth,
selectedLineStyle: this.strokeStyle,
onClick: e => this.setStrokeStyle(e),
})}
</div>
<editor-toolbar-separator
data-orientation="horizontal"
></editor-toolbar-separator>
<edgeless-color-panel
role="listbox"
aria-label="Border colors"
.value=${this.strokeColor}
.theme=${this.theme}
.palettes=${DefaultTheme.Palettes}
.hollowCircle=${this.hollowCircle}
@select=${(e: ColorEvent) => this.setStrokeColor(e)}
>
</edgeless-color-panel>
`;
}
@property({ attribute: false })
accessor hollowCircle: boolean | undefined = undefined;
@property({ attribute: false })
accessor setStrokeColor!: (e: ColorEvent) => void;
@property({ attribute: false })
accessor setStrokeStyle!: (e: LineStyleEvent) => void;
@property({ attribute: false })
accessor strokeColor!: string;
@property({ attribute: false })
accessor strokeStyle!: StrokeStyle;
@property({ attribute: false })
accessor strokeWidth!: number;
@property({ attribute: false })
accessor theme!: ColorScheme;
}
declare global {
interface HTMLElementTagNameMap {
'stroke-style-panel': StrokeStylePanel;
}
}
@@ -0,0 +1,122 @@
import type { FrameBlockModel, RootBlockModel } from '@blocksuite/affine-model';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import { WidgetComponent } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { Bound } from '@blocksuite/global/utils';
import { effect } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { state } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
export const EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET =
'edgeless-navigator-black-background';
export class EdgelessNavigatorBlackBackgroundWidget extends WidgetComponent<
RootBlockModel,
EdgelessRootBlockComponent
> {
static override styles = css`
.edgeless-navigator-black-background {
background-color: black;
position: absolute;
z-index: 1;
background-color: transparent;
box-shadow: 0 0 0 5000px black;
}
`;
private _blackBackground = false;
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
private _tryLoadBlackBackground() {
const value = this.std
.get(EditPropsStore)
.getStorage('presentBlackBackground');
this._blackBackground = value ?? true;
}
override firstUpdated() {
const { _disposables, gfx, block } = this;
_disposables.add(
block.slots.navigatorFrameChanged.on(frame => {
this.frame = frame;
})
);
_disposables.add(
block.slots.navigatorSettingUpdated.on(({ blackBackground }) => {
if (blackBackground !== undefined) {
this.std
.get(EditPropsStore)
.setStorage('presentBlackBackground', blackBackground);
this._blackBackground = blackBackground;
this.show =
blackBackground &&
block.gfx.tool.currentToolOption$.peek().type === 'frameNavigator';
}
})
);
_disposables.add(
effect(() => {
const tool = gfx.tool.currentToolName$.value;
if (tool !== 'frameNavigator') {
this.show = false;
} else {
this.show = this._blackBackground;
}
})
);
_disposables.add(
block.slots.fullScreenToggled.on(
() =>
setTimeout(() => {
this.requestUpdate();
}, 500) // wait for full screen animation
)
);
this._tryLoadBlackBackground();
}
override render() {
const { frame, show, gfx } = this;
if (!show || !frame) return nothing;
const bound = Bound.deserialize(frame.xywh);
const zoom = gfx.viewport.zoom;
const width = bound.w * zoom;
const height = bound.h * zoom;
const [x, y] = gfx.viewport.toViewCoord(bound.x, bound.y);
return html` <style>
.edgeless-navigator-black-background {
width: ${width}px;
height: ${height}px;
top: ${y}px;
left: ${x}px;
}
</style>
<div class="edgeless-navigator-black-background"></div>`;
}
@state()
private accessor frame: FrameBlockModel | undefined = undefined;
@state()
private accessor show = false;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-navigator-black-background': EdgelessNavigatorBlackBackgroundWidget;
}
}
@@ -0,0 +1,64 @@
import type { RootBlockModel } from '@blocksuite/affine-model';
import { WidgetComponent } from '@blocksuite/block-std';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, nothing, unsafeCSS } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
import { DefaultTool } from '../../gfx-tool/default-tool.js';
import { DefaultModeDragType } from '../../gfx-tool/default-tool-ext/ext.js';
export const EDGELESS_DRAGGING_AREA_WIDGET = 'edgeless-dragging-area-rect';
export class EdgelessDraggingAreaRectWidget extends WidgetComponent<
RootBlockModel,
EdgelessRootBlockComponent
> {
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;
}
`;
override render() {
const rect = this.block.gfx.tool.draggingViewArea$.value;
const tool = this.block.gfx.tool.currentTool$.value;
if (
rect.w === 0 ||
rect.h === 0 ||
!(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>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-dragging-area-rect': EdgelessDraggingAreaRectWidget;
}
}
@@ -0,0 +1,219 @@
import type { IVec } from '@blocksuite/global/utils';
import { html, nothing } from 'lit';
export enum HandleDirection {
Bottom = 'bottom',
BottomLeft = 'bottom-left',
BottomRight = 'bottom-right',
Left = 'left',
Right = 'right',
Top = 'top',
TopLeft = 'top-left',
TopRight = 'top-right',
}
function ResizeHandle(
handleDirection: HandleDirection,
onPointerDown?: (e: PointerEvent, direction: HandleDirection) => void,
updateCursor?: (
dragging: boolean,
options?: {
type: 'resize' | 'rotate';
target?: HTMLElement;
point?: IVec;
}
) => void,
hideEdgeHandle?: boolean
) {
const handlerPointerDown = (e: PointerEvent) => {
e.stopPropagation();
onPointerDown && onPointerDown(e, handleDirection);
};
const pointerEnter = (type: 'resize' | 'rotate') => (e: PointerEvent) => {
e.stopPropagation();
if (e.buttons === 1 || !updateCursor) return;
const { clientX, clientY } = e;
const target = e.target as HTMLElement;
const point: IVec = [clientX, clientY];
updateCursor(true, { type, point, target });
};
const pointerLeave = (e: PointerEvent) => {
e.stopPropagation();
if (e.buttons === 1 || !updateCursor) return;
updateCursor(false);
};
const rotationTpl =
handleDirection === HandleDirection.Top ||
handleDirection === HandleDirection.Bottom ||
handleDirection === HandleDirection.Left ||
handleDirection === HandleDirection.Right
? nothing
: html`<div
class="rotate"
@pointerover=${pointerEnter('rotate')}
@pointerout=${pointerLeave}
></div>`;
return html`<div
class="handle"
aria-label=${handleDirection}
@pointerdown=${handlerPointerDown}
>
${rotationTpl}
<div
class="resize${hideEdgeHandle && ' transparent-handle'}"
@pointerover=${pointerEnter('resize')}
@pointerout=${pointerLeave}
></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 ResizeHandles(
resizeMode: ResizeMode,
onPointerDown: (e: PointerEvent, direction: HandleDirection) => void,
updateCursor?: (
dragging: boolean,
options?: {
type: 'resize' | 'rotate';
target?: HTMLElement;
point?: IVec;
}
) => void
) {
const getCornerHandles = () => {
const handleTopLeft = ResizeHandle(
HandleDirection.TopLeft,
onPointerDown,
updateCursor
);
const handleTopRight = ResizeHandle(
HandleDirection.TopRight,
onPointerDown,
updateCursor
);
const handleBottomLeft = ResizeHandle(
HandleDirection.BottomLeft,
onPointerDown,
updateCursor
);
const handleBottomRight = ResizeHandle(
HandleDirection.BottomRight,
onPointerDown,
updateCursor
);
return {
handleTopLeft,
handleTopRight,
handleBottomLeft,
handleBottomRight,
};
};
const getEdgeHandles = (hideEdgeHandle?: boolean) => {
const handleLeft = ResizeHandle(
HandleDirection.Left,
onPointerDown,
updateCursor,
hideEdgeHandle
);
const handleRight = ResizeHandle(
HandleDirection.Right,
onPointerDown,
updateCursor,
hideEdgeHandle
);
return { handleLeft, handleRight };
};
const getEdgeVerticalHandles = (hideEdgeHandle?: boolean) => {
const handleTop = ResizeHandle(
HandleDirection.Top,
onPointerDown,
updateCursor,
hideEdgeHandle
);
const handleBottom = ResizeHandle(
HandleDirection.Bottom,
onPointerDown,
updateCursor,
hideEdgeHandle
);
return { handleTop, handleBottom };
};
switch (resizeMode) {
case 'corner': {
const {
handleTopLeft,
handleTopRight,
handleBottomLeft,
handleBottomRight,
} = getCornerHandles();
// prettier-ignore
return html`
${handleTopLeft}
${handleTopRight}
${handleBottomLeft}
${handleBottomRight}
`;
}
case 'edge': {
const { handleLeft, handleRight } = getEdgeHandles();
return html`${handleLeft} ${handleRight}`;
}
case 'all': {
const {
handleTopLeft,
handleTopRight,
handleBottomLeft,
handleBottomRight,
} = getCornerHandles();
const { handleLeft, handleRight } = getEdgeHandles(true);
const { handleTop, handleBottom } = getEdgeVerticalHandles(true);
// prettier-ignore
return html`
${handleTopLeft}
${handleTop}
${handleTopRight}
${handleRight}
${handleBottomRight}
${handleBottom}
${handleBottomLeft}
${handleLeft}
`;
}
case 'edgeAndCorner': {
const {
handleTopLeft,
handleTopRight,
handleBottomLeft,
handleBottomRight,
} = getCornerHandles();
const { handleLeft, handleRight } = getEdgeHandles(true);
return html`
${handleTopLeft} ${handleTopRight} ${handleRight} ${handleBottomRight}
${handleBottomLeft} ${handleLeft}
`;
}
case 'none': {
return nothing;
}
}
}
@@ -0,0 +1,706 @@
import { NOTE_MIN_WIDTH } from '@blocksuite/affine-model';
import {
assertExists,
Bound,
getQuadBoundWithRotation,
type IPoint,
type IVec,
type PointLocation,
rotatePoints,
} from '@blocksuite/global/utils';
import type { SelectableProps } from '../../utils/query.js';
import { HandleDirection, type ResizeMode } from './resize-handles.js';
// 15deg
const SHIFT_LOCKING_ANGLE = Math.PI / 12;
type DragStartHandler = () => void;
type DragEndHandler = () => void;
type ResizeMoveHandler = (
bounds: Map<
string,
{
bound: Bound;
path?: PointLocation[];
matrix?: DOMMatrix;
}
>,
direction: HandleDirection
) => void;
type RotateMoveHandler = (point: IPoint, rotate: number) => void;
export class HandleResizeManager {
private _aspectRatio = 1;
private _bounds = new Map<
string,
{
bound: Bound;
rotate: number;
}
>();
/**
* Current rect of selected elements, it may change during resizing or moving
*/
private _currentRect = new DOMRect();
private _dragDirection: HandleDirection = HandleDirection.Left;
private _dragging = false;
private _dragPos: {
start: { x: number; y: number };
end: { x: number; y: number };
} = {
start: { x: 0, y: 0 },
end: { x: 0, y: 0 },
};
private _locked = false;
private readonly _onDragEnd: DragEndHandler;
private readonly _onDragStart: DragStartHandler;
private readonly _onResizeMove: ResizeMoveHandler;
private readonly _onRotateMove: RotateMoveHandler;
private _origin: { x: number; y: number } = { x: 0, y: 0 };
/**
* Record inital rect of selected elements
*/
private _originalRect = new DOMRect();
private _proportion = false;
private _proportional = false;
private _resizeMode: ResizeMode = 'none';
private _rotate = 0;
private _rotation = false;
private _shiftKey = false;
private _target: HTMLElement | null = null;
private _zoom = 1;
onPointerDown = (
e: PointerEvent,
direction: HandleDirection,
proportional = false
) => {
// Prevent selection action from being triggered
e.stopPropagation();
this._locked = false;
this._target = e.target as HTMLElement;
this._dragDirection = direction;
this._dragPos.start = { x: e.x, y: e.y };
this._dragPos.end = { x: e.x, y: e.y };
this._rotation = this._target.classList.contains('rotate');
this._proportional = proportional;
if (this._rotation) {
const rect = this._target
.closest('.affine-edgeless-selected-rect')
?.getBoundingClientRect();
assertExists(rect);
const { left, top, right, bottom } = rect;
const x = (left + right) / 2;
const y = (top + bottom) / 2;
// center of `selected-rect` in viewport
this._origin = { x, y };
}
this._dragging = true;
this._onDragStart();
const _onPointerMove = ({ x, y, shiftKey }: PointerEvent) => {
if (this._resizeMode === 'none') return;
this._shiftKey = shiftKey;
this._dragPos.end = { x, y };
const proportional = this._proportional || this._shiftKey;
if (this._rotation) {
this._onRotate(proportional);
return;
}
this._onResize(proportional);
};
const _onPointerUp = (_: PointerEvent) => {
this._dragging = false;
this._onDragEnd();
const { x, y, width, height } = this._currentRect;
this._originalRect = new DOMRect(x, y, width, height);
this._locked = true;
this._shiftKey = false;
this._rotation = false;
this._dragPos = {
start: { x: 0, y: 0 },
end: { x: 0, y: 0 },
};
document.removeEventListener('pointermove', _onPointerMove);
document.removeEventListener('pointerup', _onPointerUp);
};
document.addEventListener('pointermove', _onPointerMove);
document.addEventListener('pointerup', _onPointerUp);
};
get bounds() {
return this._bounds;
}
get currentRect() {
return this._currentRect;
}
get dragDirection() {
return this._dragDirection;
}
get dragging() {
return this._dragging;
}
get originalRect() {
return this._originalRect;
}
get rotation() {
return this._rotation;
}
constructor(
onDragStart: DragStartHandler,
onResizeMove: ResizeMoveHandler,
onRotateMove: RotateMoveHandler,
onDragEnd: DragEndHandler
) {
this._onDragStart = onDragStart;
this._onResizeMove = onResizeMove;
this._onRotateMove = onRotateMove;
this._onDragEnd = onDragEnd;
}
private _onResize(proportion: boolean) {
const {
_aspectRatio,
_dragDirection,
_dragPos,
_rotate,
_resizeMode,
_zoom,
_target,
_originalRect,
_currentRect,
} = this;
proportion ||= this._proportion;
assertExists(_target);
const isAll = _resizeMode === 'all';
const isCorner = _resizeMode === 'corner';
const isEdgeAndCorner = _resizeMode === 'edgeAndCorner';
const {
start: { x: startX, y: startY },
end: { x: endX, y: endY },
} = _dragPos;
const { left: minX, top: minY, right: maxX, bottom: maxY } = _originalRect;
const original = {
w: maxX - minX,
h: maxY - minY,
cx: (minX + maxX) / 2,
cy: (minY + maxY) / 2,
};
const rect = { ...original };
const scale = { x: 1, y: 1 };
const flip = { x: 1, y: 1 };
const direction = { x: 1, y: 1 };
const fixedPoint = new DOMPoint(0, 0);
const draggingPoint = new DOMPoint(0, 0);
const deltaX = (endX - startX) / _zoom;
const deltaY = (endY - startY) / _zoom;
const m0 = new DOMMatrix()
.translateSelf(original.cx, original.cy)
.rotateSelf(_rotate)
.translateSelf(-original.cx, -original.cy);
if (isCorner || isAll || isEdgeAndCorner) {
switch (_dragDirection) {
case HandleDirection.TopLeft: {
direction.x = -1;
direction.y = -1;
fixedPoint.x = maxX;
fixedPoint.y = maxY;
draggingPoint.x = minX;
draggingPoint.y = minY;
break;
}
case HandleDirection.TopRight: {
direction.x = 1;
direction.y = -1;
fixedPoint.x = minX;
fixedPoint.y = maxY;
draggingPoint.x = maxX;
draggingPoint.y = minY;
break;
}
case HandleDirection.BottomRight: {
direction.x = 1;
direction.y = 1;
fixedPoint.x = minX;
fixedPoint.y = minY;
draggingPoint.x = maxX;
draggingPoint.y = maxY;
break;
}
case HandleDirection.BottomLeft: {
direction.x = -1;
direction.y = 1;
fixedPoint.x = maxX;
fixedPoint.y = minY;
draggingPoint.x = minX;
draggingPoint.y = maxY;
break;
}
case HandleDirection.Left: {
direction.x = -1;
direction.y = 1;
fixedPoint.x = maxX;
fixedPoint.y = original.cy;
draggingPoint.x = minX;
draggingPoint.y = original.cy;
break;
}
case HandleDirection.Right: {
direction.x = 1;
direction.y = 1;
fixedPoint.x = minX;
fixedPoint.y = original.cy;
draggingPoint.x = maxX;
draggingPoint.y = original.cy;
break;
}
case HandleDirection.Top: {
const cx = (minX + maxX) / 2;
direction.x = 1;
direction.y = -1;
fixedPoint.x = cx;
fixedPoint.y = maxY;
draggingPoint.x = cx;
draggingPoint.y = minY;
break;
}
case HandleDirection.Bottom: {
const cx = (minX + maxX) / 2;
direction.x = 1;
direction.y = 1;
fixedPoint.x = cx;
fixedPoint.y = minY;
draggingPoint.x = cx;
draggingPoint.y = maxY;
break;
}
}
// force adjustment by aspect ratio
proportion ||= this._bounds.size > 1;
const fp = fixedPoint.matrixTransform(m0);
let dp = draggingPoint.matrixTransform(m0);
dp.x += deltaX;
dp.y += deltaY;
if (
_dragDirection === HandleDirection.Left ||
_dragDirection === HandleDirection.Right ||
_dragDirection === HandleDirection.Top ||
_dragDirection === HandleDirection.Bottom
) {
const dpo = draggingPoint.matrixTransform(m0);
const coorPoint: IVec = [0, 0];
const [[x1, y1]] = rotatePoints([[dpo.x, dpo.y]], coorPoint, -_rotate);
const [[x2, y2]] = rotatePoints([[dp.x, dp.y]], coorPoint, -_rotate);
const point = { x: 0, y: 0 };
if (
_dragDirection === HandleDirection.Left ||
_dragDirection === HandleDirection.Right
) {
point.x = x2;
point.y = y1;
} else {
point.x = x1;
point.y = y2;
}
const [[x3, y3]] = rotatePoints(
[[point.x, point.y]],
coorPoint,
_rotate
);
dp.x = x3;
dp.y = y3;
}
const cx = (fp.x + dp.x) / 2;
const cy = (fp.y + dp.y) / 2;
const m1 = new DOMMatrix()
.translateSelf(cx, cy)
.rotateSelf(-_rotate)
.translateSelf(-cx, -cy);
const f = fp.matrixTransform(m1);
const d = dp.matrixTransform(m1);
switch (_dragDirection) {
case HandleDirection.TopLeft: {
rect.w = f.x - d.x;
rect.h = f.y - d.y;
break;
}
case HandleDirection.TopRight: {
rect.w = d.x - f.x;
rect.h = f.y - d.y;
break;
}
case HandleDirection.BottomRight: {
rect.w = d.x - f.x;
rect.h = d.y - f.y;
break;
}
case HandleDirection.BottomLeft: {
rect.w = f.x - d.x;
rect.h = d.y - f.y;
break;
}
case HandleDirection.Left: {
rect.w = f.x - d.x;
break;
}
case HandleDirection.Right: {
rect.w = d.x - f.x;
break;
}
case HandleDirection.Top: {
rect.h = f.y - d.y;
break;
}
case HandleDirection.Bottom: {
rect.h = d.y - f.y;
break;
}
}
rect.cx = (d.x + f.x) / 2;
rect.cy = (d.y + f.y) / 2;
scale.x = rect.w / original.w;
scale.y = rect.h / original.h;
flip.x = scale.x < 0 ? -1 : 1;
flip.y = scale.y < 0 ? -1 : 1;
const isDraggingCorner =
_dragDirection === HandleDirection.TopLeft ||
_dragDirection === HandleDirection.TopRight ||
_dragDirection === HandleDirection.BottomRight ||
_dragDirection === HandleDirection.BottomLeft;
// lock aspect ratio
if (proportion && isDraggingCorner) {
const newAspectRatio = Math.abs(rect.w / rect.h);
if (_aspectRatio < newAspectRatio) {
scale.y = Math.abs(scale.x) * flip.y;
rect.h = scale.y * original.h;
} else {
scale.x = Math.abs(scale.y) * flip.x;
rect.w = scale.x * original.w;
}
draggingPoint.x = fixedPoint.x + rect.w * direction.x;
draggingPoint.y = fixedPoint.y + rect.h * direction.y;
dp = draggingPoint.matrixTransform(m0);
rect.cx = (fp.x + dp.x) / 2;
rect.cy = (fp.y + dp.y) / 2;
}
} else {
// handle notes
switch (_dragDirection) {
case HandleDirection.Left: {
direction.x = -1;
fixedPoint.x = maxX;
draggingPoint.x = minX + deltaX;
rect.w = fixedPoint.x - draggingPoint.x;
break;
}
case HandleDirection.Right: {
direction.x = 1;
fixedPoint.x = minX;
draggingPoint.x = maxX + deltaX;
rect.w = draggingPoint.x - fixedPoint.x;
break;
}
}
scale.x = rect.w / original.w;
flip.x = scale.x < 0 ? -1 : 1;
if (Math.abs(rect.w) < NOTE_MIN_WIDTH) {
rect.w = NOTE_MIN_WIDTH * flip.x;
scale.x = rect.w / original.w;
draggingPoint.x = fixedPoint.x + rect.w * direction.x;
}
rect.cx = (draggingPoint.x + fixedPoint.x) / 2;
}
const width = Math.abs(rect.w);
const height = Math.abs(rect.h);
const x = rect.cx - width / 2;
const y = rect.cy - height / 2;
_currentRect.x = x;
_currentRect.y = y;
_currentRect.width = width;
_currentRect.height = height;
const newBounds = new Map<
string,
{
bound: Bound;
path?: PointLocation[];
matrix?: DOMMatrix;
}
>();
let process: (value: SelectableProps, key: string) => void;
if (isCorner || isAll || isEdgeAndCorner) {
if (this._bounds.size === 1) {
process = (_, id) => {
newBounds.set(id, {
bound: new Bound(x, y, width, height),
});
};
} else {
const fp = fixedPoint.matrixTransform(m0);
const m2 = new DOMMatrix()
.translateSelf(fp.x, fp.y)
.rotateSelf(_rotate)
.translateSelf(-fp.x, -fp.y)
.scaleSelf(scale.x, scale.y, 1, fp.x, fp.y, 0)
.translateSelf(fp.x, fp.y)
.rotateSelf(-_rotate)
.translateSelf(-fp.x, -fp.y);
// TODO: on same rotate
process = ({ bound: { x, y, w, h }, path }, id) => {
const cx = x + w / 2;
const cy = y + h / 2;
const center = new DOMPoint(cx, cy).matrixTransform(m2);
const newWidth = Math.abs(w * scale.x);
const newHeight = Math.abs(h * scale.y);
newBounds.set(id, {
bound: new Bound(
center.x - newWidth / 2,
center.y - newHeight / 2,
newWidth,
newHeight
),
matrix: m2,
path,
});
};
}
} else {
// include notes, <---->
const m2 = new DOMMatrix().scaleSelf(
scale.x,
scale.y,
1,
fixedPoint.x,
fixedPoint.y,
0
);
process = ({ bound: { x, y, w, h }, rotate = 0, path }, id) => {
const cx = x + w / 2;
const cy = y + h / 2;
const center = new DOMPoint(cx, cy).matrixTransform(m2);
let newWidth: number;
let newHeight: number;
// TODO: determine if it is a note
if (rotate) {
const { width } = getQuadBoundWithRotation({ x, y, w, h, rotate });
const hrw = width / 2;
center.y = cy;
if (_currentRect.width <= width) {
newWidth = w * (_currentRect.width / width);
newHeight = newWidth / (w / h);
center.x = _currentRect.left + _currentRect.width / 2;
} else {
const p = (cx - hrw - _originalRect.left) / _originalRect.width;
const lx = _currentRect.left + p * _currentRect.width + hrw;
center.x = Math.max(
_currentRect.left + hrw,
Math.min(lx, _currentRect.left + _currentRect.width - hrw)
);
newWidth = w;
newHeight = h;
}
} else {
newWidth = Math.abs(w * scale.x);
newHeight = Math.abs(h * scale.y);
}
newBounds.set(id, {
bound: new Bound(
center.x - newWidth / 2,
center.y - newHeight / 2,
newWidth,
newHeight
),
matrix: m2,
path,
});
};
}
this._bounds.forEach(process);
this._onResizeMove(newBounds, this._dragDirection);
}
private _onRotate(shiftKey = false) {
const {
_originalRect: { left: minX, top: minY, right: maxX, bottom: maxY },
_dragPos: {
start: { x: startX, y: startY },
end: { x: endX, y: endY },
},
_origin: { x: centerX, y: centerY },
_rotate,
} = this;
const startRad = Math.atan2(startY - centerY, startX - centerX);
const endRad = Math.atan2(endY - centerY, endX - centerX);
let deltaRad = endRad - startRad;
// snap angle
// 15deg * n = 0, 15, 30, 45, ... 360
if (shiftKey) {
const prevRad = (_rotate * Math.PI) / 180;
let angle = prevRad + deltaRad;
angle += SHIFT_LOCKING_ANGLE / 2;
angle -= angle % SHIFT_LOCKING_ANGLE;
deltaRad = angle - prevRad;
}
const delta = (deltaRad * 180) / Math.PI;
let x = endX;
let y = endY;
if (shiftKey) {
const point = new DOMPoint(startX, startY).matrixTransform(
new DOMMatrix()
.translateSelf(centerX, centerY)
.rotateSelf(delta)
.translateSelf(-centerX, -centerY)
);
x = point.x;
y = point.y;
}
this._onRotateMove(
// center of element in suface
{ x: (minX + maxX) / 2, y: (minY + maxY) / 2 },
delta
);
this._dragPos.start = { x, y };
this._rotate += delta;
}
onPressShiftKey(pressed: boolean) {
if (!this._target) return;
if (this._locked) return;
if (this._shiftKey === pressed) return;
this._shiftKey = pressed;
const proportional = this._proportional || this._shiftKey;
if (this._rotation) {
this._onRotate(proportional);
return;
}
this._onResize(proportional);
}
updateBounds(bounds: Map<string, SelectableProps>) {
this._bounds = bounds;
}
updateRectPosition(delta: { x: number; y: number }) {
this._currentRect.x += delta.x;
this._currentRect.y += delta.y;
this._originalRect.x = this._currentRect.x;
this._originalRect.y = this._currentRect.y;
return this._originalRect;
}
updateState(
resizeMode: ResizeMode,
rotate: number,
zoom: number,
position?: { x: number; y: number },
originalRect?: DOMRect,
proportion = false
) {
this._resizeMode = resizeMode;
this._rotate = rotate;
this._zoom = zoom;
this._proportion = proportion;
if (position) {
this._currentRect.x = position.x;
this._currentRect.y = position.y;
this._originalRect.x = this._currentRect.x;
this._originalRect.y = this._currentRect.y;
}
if (originalRect) {
this._originalRect = originalRect;
this._aspectRatio = originalRect.width / originalRect.height;
this._currentRect = DOMRect.fromRect(originalRect);
}
}
}
@@ -0,0 +1,334 @@
import {
EdgelessCRUDIdentifier,
TextUtils,
} from '@blocksuite/affine-block-surface';
import type { RichText } from '@blocksuite/affine-components/rich-text';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { almostEqual } from '@blocksuite/affine-shared/utils';
import {
RANGE_SYNC_EXCLUDE_ATTR,
ShadowlessElement,
} from '@blocksuite/block-std';
import {
assertExists,
Bound,
Vec,
WithDisposable,
} from '@blocksuite/global/utils';
import { css, html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import * as Y from 'yjs';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
const HORIZONTAL_PADDING = 2;
const VERTICAL_PADDING = 2;
const BORDER_WIDTH = 1;
export class EdgelessConnectorLabelEditor extends WithDisposable(
ShadowlessElement
) {
static override styles = css`
.edgeless-connector-label-editor {
position: absolute;
left: 0;
top: 0;
transform-origin: center;
z-index: 10;
padding: ${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px;
border: ${BORDER_WIDTH}px solid var(--affine-primary-color, #1e96eb);
background: var(--affine-background-primary-color, #fff);
border-radius: 2px;
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
box-sizing: border-box;
overflow: visible;
.inline-editor {
white-space: pre-wrap !important;
outline: none;
}
.inline-editor span {
word-break: normal !important;
overflow-wrap: anywhere !important;
}
.edgeless-connector-label-editor-placeholder {
pointer-events: none;
color: var(--affine-text-disable-color);
white-space: nowrap;
}
}
`;
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private _isComposition = false;
private _keeping = false;
private _resizeObserver: ResizeObserver | null = null;
private readonly _updateLabelRect = () => {
const { connector, edgeless } = this;
if (!connector || !edgeless) return;
if (!this.inlineEditorContainer) return;
const newWidth = this.inlineEditorContainer.scrollWidth;
const newHeight = this.inlineEditorContainer.scrollHeight;
const center = connector.getPointByOffsetDistance(
connector.labelOffset.distance
);
const bounds = Bound.fromCenter(center, newWidth, newHeight);
const labelXYWH = bounds.toXYWH();
if (
!connector.labelXYWH ||
labelXYWH.some((p, i) => !almostEqual(p, connector.labelXYWH![i]))
) {
this.crud.updateElement(connector.id, {
labelXYWH,
});
}
};
get inlineEditor() {
assertExists(this.richText.inlineEditor);
return this.richText.inlineEditor;
}
get inlineEditorContainer() {
return this.inlineEditor.rootElement;
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override disconnectedCallback() {
super.disconnectedCallback();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
}
override firstUpdated() {
const { edgeless, connector } = this;
const { dispatcher } = edgeless;
assertExists(dispatcher);
this._resizeObserver = new ResizeObserver(() => {
this._updateLabelRect();
this.requestUpdate();
});
this._resizeObserver.observe(this.richText);
this.updateComplete
.then(() => {
this.inlineEditor.selectAll();
this.inlineEditor.slots.renderComplete.on(() => {
this.requestUpdate();
});
this.disposables.add(
dispatcher.add('keyDown', ctx => {
const state = ctx.get('keyboardState');
const { key, ctrlKey, metaKey, altKey, shiftKey, isComposing } =
state.raw;
const onlyCmd = (ctrlKey || metaKey) && !altKey && !shiftKey;
const isModEnter = onlyCmd && key === 'Enter';
const isEscape = key === 'Escape';
if (!isComposing && (isModEnter || isEscape)) {
this.inlineEditorContainer?.blur();
edgeless.service.selection.set({
elements: [connector.id],
editing: false,
});
return true;
}
return false;
})
);
this.disposables.add(
edgeless.service.surface.elementUpdated.on(({ id }) => {
if (id === connector.id) this.requestUpdate();
})
);
this.disposables.add(
edgeless.service.viewport.viewportUpdated.on(() => {
this.requestUpdate();
})
);
this.disposables.add(dispatcher.add('click', () => true));
this.disposables.add(dispatcher.add('doubleClick', () => true));
this.disposables.add(() => {
if (connector.text) {
const text = connector.text.toString();
const trimed = text.trim();
const len = trimed.length;
if (len === 0) {
// reset
this.crud.updateElement(connector.id, {
text: undefined,
labelXYWH: undefined,
labelOffset: undefined,
});
} else if (len < text.length) {
this.crud.updateElement(connector.id, {
// @TODO: trim in Y.Text?
text: new Y.Text(trimed),
});
}
}
connector.lableEditing = false;
edgeless.service.selection.set({
elements: [],
editing: false,
});
});
if (!this.inlineEditorContainer) return;
this.disposables.addFromEvent(
this.inlineEditorContainer,
'blur',
() => {
if (this._keeping) return;
this.remove();
}
);
this.disposables.addFromEvent(
this.inlineEditorContainer,
'compositionstart',
() => {
this._isComposition = true;
this.requestUpdate();
}
);
this.disposables.addFromEvent(
this.inlineEditorContainer,
'compositionend',
() => {
this._isComposition = false;
this.requestUpdate();
}
);
connector.lableEditing = true;
})
.catch(console.error);
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.richText?.updateComplete;
return result;
}
override render() {
const { connector } = this;
const {
labelOffset: { distance },
labelStyle: {
fontFamily,
fontSize,
fontStyle,
fontWeight,
textAlign,
color: labelColor,
},
labelConstraints: { hasMaxWidth, maxWidth },
} = connector;
const lineHeight = TextUtils.getLineHeight(
fontFamily,
fontSize,
fontWeight
);
const { translateX, translateY, zoom } = this.edgeless.service.viewport;
const [x, y] = Vec.mul(connector.getPointByOffsetDistance(distance), zoom);
const transformOperation = [
'translate(-50%, -50%)',
`translate(${translateX}px, ${translateY}px)`,
`translate(${x}px, ${y}px)`,
`scale(${zoom})`,
];
const isEmpty = !connector.text?.length && !this._isComposition;
const color = this.edgeless.std
.get(ThemeProvider)
.generateColorProperty(labelColor, '#000000');
return html`
<div
class="edgeless-connector-label-editor"
style=${styleMap({
fontFamily: `"${fontFamily}"`,
fontSize: `${fontSize}px`,
fontStyle,
fontWeight,
textAlign,
lineHeight: `${lineHeight}px`,
maxWidth: hasMaxWidth
? `${maxWidth + BORDER_WIDTH * 2 + HORIZONTAL_PADDING * 2}px`
: 'initial',
color,
transform: transformOperation.join(' '),
})}
>
<rich-text
.yText=${connector.text}
.enableFormat=${false}
style=${isEmpty
? styleMap({
position: 'absolute',
left: 0,
top: 0,
padding: `${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px`,
})
: nothing}
></rich-text>
${isEmpty
? html`
<span class="edgeless-connector-label-editor-placeholder">
Add text
</span>
`
: nothing}
</div>
`;
}
setKeeping(keeping: boolean) {
this._keeping = keeping;
}
@property({ attribute: false })
accessor connector!: ConnectorElementModel;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@query('rich-text')
accessor richText!: RichText;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-connector-label-editor': EdgelessConnectorLabelEditor;
}
}
@@ -0,0 +1,183 @@
import type { RichText } from '@blocksuite/affine-components/rich-text';
import { FrameBlockModel } from '@blocksuite/affine-model';
import {
AFFINE_FRAME_TITLE_WIDGET,
type AffineFrameTitleWidget,
frameTitleStyleVars,
} from '@blocksuite/affine-widget-frame-title';
import {
RANGE_SYNC_EXCLUDE_ATTR,
ShadowlessElement,
} from '@blocksuite/block-std';
import { assertExists, Bound, WithDisposable } from '@blocksuite/global/utils';
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 type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
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;
}
private _unmount() {
// dispose in advance to avoid execute `this.remove()` twice
this.disposables.dispose();
this.edgeless.service.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.dispatcher;
assertExists(dispatcher);
this.updateComplete
.then(() => {
if (!this.inlineEditor) return;
this.inlineEditor.selectAll();
this.inlineEditor.slots.renderComplete.on(() => {
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.edgeless.service.viewport.viewportUpdated.on(() => {
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.doc.root?.id;
if (!rootBlockId) return nothing;
const viewport = this.edgeless.service.viewport;
const bound = Bound.deserialize(this.frameModel.xywh);
const [x, y] = viewport.toViewCoord(bound.x, bound.y);
const isInner = this.edgeless.service.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,
rootBlockId
) as AffineFrameTitleWidget | null;
if (!frameTitleWidget) return nothing;
const frameTitle = frameTitleWidget.getFrameTitle(this.frameModel);
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.title.yText}
.enableFormat=${false}
.enableAutoScrollHorizontally=${false}
style=${richTextStyle}
></rich-text>
</div>`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor frameModel!: FrameBlockModel;
@query('rich-text')
accessor richText: RichText | null = null;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-frame-title-editor': EdgelessFrameTitleEditor;
}
}
@@ -0,0 +1,153 @@
import {
GROUP_TITLE_FONT_SIZE,
GROUP_TITLE_OFFSET,
GROUP_TITLE_PADDING,
} from '@blocksuite/affine-block-surface';
import type { RichText } from '@blocksuite/affine-components/rich-text';
import type { GroupElementModel } from '@blocksuite/affine-model';
import {
RANGE_SYNC_EXCLUDE_ATTR,
ShadowlessElement,
} from '@blocksuite/block-std';
import { assertExists, Bound, WithDisposable } from '@blocksuite/global/utils';
import { html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
export class EdgelessGroupTitleEditor extends WithDisposable(
ShadowlessElement
) {
get inlineEditor() {
assertExists(this.richText.inlineEditor);
return this.richText.inlineEditor;
}
get inlineEditorContainer() {
return this.inlineEditor.rootElement;
}
private _unmount() {
// dispose in advance to avoid execute `this.remove()` twice
this.disposables.dispose();
this.group.showTitle = true;
this.edgeless.service.selection.set({
elements: [this.group.id],
editing: false,
});
this.remove();
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override firstUpdated(): void {
const dispatcher = this.edgeless.dispatcher;
assertExists(dispatcher);
this.updateComplete
.then(() => {
this.inlineEditor.selectAll();
this.group.showTitle = false;
this.inlineEditor.slots.renderComplete.on(() => {
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.edgeless.service.viewport.viewportUpdated.on(() => {
this.requestUpdate();
})
);
this.disposables.add(dispatcher.add('click', () => true));
this.disposables.add(dispatcher.add('doubleClick', () => true));
if (!this.inlineEditorContainer) return;
this.disposables.addFromEvent(
this.inlineEditorContainer,
'blur',
() => {
this._unmount();
}
);
})
.catch(console.error);
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.richText?.updateComplete;
return result;
}
override render() {
if (!this.group.externalXYWH) {
console.error('group.externalXYWH is not set');
return nothing;
}
const viewport = this.edgeless.service.viewport;
const bound = Bound.deserialize(this.group.externalXYWH);
const [x, y] = viewport.toViewCoord(bound.x, bound.y);
const inlineEditorStyle = styleMap({
transformOrigin: 'top left',
borderRadius: '2px',
width: 'fit-content',
maxHeight: '30px',
height: 'fit-content',
padding: `${GROUP_TITLE_PADDING[1]}px ${GROUP_TITLE_PADDING[0]}px`,
fontSize: GROUP_TITLE_FONT_SIZE + 'px',
position: 'absolute',
left: x + 'px',
top: `${y - GROUP_TITLE_OFFSET + 2}px`,
minWidth: '8px',
fontFamily: 'var(--affine-font-family)',
color: 'var(--affine-text-primary-color)',
background: 'var(--affine-white-10)',
outline: 'none',
zIndex: '1',
border: `1px solid
var(--affine-primary-color)`,
boxShadow: 'var(--affine-active-shadow)',
});
return html`<rich-text
.yText=${this.group.title}
.enableFormat=${false}
.enableAutoScrollHorizontally=${false}
style=${inlineEditorStyle}
></rich-text>`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor group!: GroupElementModel;
@query('rich-text')
accessor richText!: RichText;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-group-title-editor': EdgelessGroupTitleEditor;
}
}
@@ -0,0 +1,374 @@
import {
EdgelessCRUDIdentifier,
TextUtils,
} from '@blocksuite/affine-block-surface';
import type { RichText } from '@blocksuite/affine-components/rich-text';
import type { ShapeElementModel } from '@blocksuite/affine-model';
import { MindmapElementModel, TextResizing } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { getSelectedRect } from '@blocksuite/affine-shared/utils';
import {
RANGE_SYNC_EXCLUDE_ATTR,
ShadowlessElement,
} from '@blocksuite/block-std';
import {
assertExists,
Bound,
toRadian,
Vec,
WithDisposable,
} from '@blocksuite/global/utils';
import { html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import * as Y from 'yjs';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private _keeping = false;
private _lastXYWH = '';
private _resizeObserver: ResizeObserver | null = null;
get inlineEditor() {
assertExists(this.richText.inlineEditor);
return this.richText.inlineEditor;
}
get inlineEditorContainer() {
return this.inlineEditor.rootElement;
}
get isMindMapNode() {
return this.element.group instanceof MindmapElementModel;
}
private _initMindmapKeyBindings() {
if (!this.isMindMapNode) {
return;
}
const service = this.edgeless.service;
this._disposables.addFromEvent(this, 'keydown', evt => {
switch (evt.key) {
case 'Enter': {
evt.stopPropagation();
if (evt.shiftKey || evt.isComposing) return;
(this.ownerDocument.activeElement as HTMLElement).blur();
service.selection.set({
elements: [this.element.id],
editing: false,
});
break;
}
case 'Esc':
case 'Tab': {
evt.stopPropagation();
(this.ownerDocument.activeElement as HTMLElement).blur();
service.selection.set({
elements: [this.element.id],
editing: false,
});
break;
}
}
});
}
private _stashMindMapTree() {
if (!this.isMindMapNode) {
return;
}
const mindmap = this.element.group as MindmapElementModel;
const pop = mindmap.stashTree(mindmap.tree);
this._disposables.add(() => {
mindmap.layout();
pop?.();
});
}
private _unmount() {
this._resizeObserver?.disconnect();
this._resizeObserver = null;
if (this.element.text) {
const text = this.element.text.toString();
const trimed = text.trim();
const len = trimed.length;
if (len === 0) {
this.element.text = undefined;
} else if (len < text.length) {
this.element.text = new Y.Text(trimed);
}
}
this.element.textDisplay = true;
this.remove();
this.edgeless.service.selection.set({
elements: [],
editing: false,
});
}
private _updateElementWH() {
const bcr = this.richText.getBoundingClientRect();
const containerHeight = this.richText.offsetHeight;
const containerWidth = this.richText.offsetWidth;
const textResizing = this.element.textResizing;
if (
(containerHeight !== this.element.h &&
textResizing === TextResizing.AUTO_HEIGHT) ||
(textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT &&
(containerWidth !== this.element.w ||
containerHeight !== this.element.h))
) {
const [leftTopX, leftTopY] = Vec.rotWith(
[this.richText.offsetLeft, this.richText.offsetTop],
[bcr.left + bcr.width / 2, bcr.top + bcr.height / 2],
toRadian(-this.element.rotate)
);
const [modelLeftTopX, modelLeftTopY] =
this.edgeless.service.viewport.toModelCoord(leftTopX, leftTopY);
this.crud.updateElement(this.element.id, {
xywh: new Bound(
modelLeftTopX,
modelLeftTopY,
textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT
? containerWidth
: this.element.w,
containerHeight
).serialize(),
});
if (this._lastXYWH !== this.element.xywh) {
this.requestUpdate();
}
if (this.isMindMapNode) {
const mindmap = this.element.group as MindmapElementModel;
mindmap.layout();
}
this.richText.style.minHeight = `${containerHeight}px`;
}
this.edgeless.service.selection.set({
elements: [this.element.id],
editing: true,
});
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override firstUpdated(): void {
const dispatcher = this.edgeless.dispatcher;
assertExists(dispatcher);
this.element.textDisplay = false;
this.disposables.add(
this.edgeless.service.viewport.viewportUpdated.on(() => {
this.requestUpdate();
this.updateComplete
.then(() => {
this._updateElementWH();
})
.catch(console.error);
})
);
this.disposables.add(
dispatcher.add('click', () => {
return true;
})
);
this.disposables.add(
dispatcher.add('doubleClick', () => {
return true;
})
);
this.updateComplete
.then(() => {
if (this.element.group instanceof MindmapElementModel) {
this.inlineEditor.selectAll();
} else {
this.inlineEditor.focusEnd();
}
this.disposables.add(
this.inlineEditor.slots.renderComplete.on(() => {
this._updateElementWH();
})
);
if (!this.inlineEditorContainer) return;
this.disposables.addFromEvent(
this.inlineEditorContainer,
'blur',
() => {
if (this._keeping) return;
this._unmount();
}
);
})
.catch(console.error);
this.disposables.addFromEvent(this, 'keydown', evt => {
if (evt.key === 'Escape') {
requestAnimationFrame(() => {
this.edgeless.service.selection.set({
elements: [this.element.id],
editing: false,
});
});
(this.ownerDocument.activeElement as HTMLElement).blur();
}
});
this._initMindmapKeyBindings();
this._stashMindMapTree();
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.richText?.updateComplete;
return result;
}
override render() {
if (!this.element.text) {
console.error('Failed to mount shape editor because of no text.');
return nothing;
}
const [verticalPadding, horiPadding] = this.element.padding;
const textResizing = this.element.textResizing;
const viewport = this.edgeless.service.viewport;
const zoom = viewport.zoom;
const rect = getSelectedRect([this.element]);
const rotate = this.element.rotate;
const [leftTopX, leftTopY] = Vec.rotWith(
[rect.left, rect.top],
[rect.left + rect.width / 2, rect.top + rect.height / 2],
toRadian(rotate)
);
const [x, y] = this.edgeless.service.viewport.toViewCoord(
leftTopX,
leftTopY
);
const autoWidth = textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT;
const color = this.edgeless.std
.get(ThemeProvider)
.generateColorProperty(this.element.color, '#000000');
const inlineEditorStyle = styleMap({
position: 'absolute',
left: x + 'px',
top: y + 'px',
width:
textResizing === TextResizing.AUTO_HEIGHT
? rect.width + 'px'
: 'fit-content',
// override rich-text style (height: 100%)
height: 'initial',
minHeight:
textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT
? '1em'
: `${rect.height}px`,
maxWidth:
textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT
? this.element.maxWidth
? `${this.element.maxWidth}px`
: undefined
: undefined,
boxSizing: 'border-box',
fontSize: this.element.fontSize + 'px',
fontFamily: TextUtils.wrapFontFamily(this.element.fontFamily),
fontWeight: this.element.fontWeight,
lineHeight: 'normal',
outline: 'none',
transform: `scale(${zoom}, ${zoom}) rotate(${rotate}deg)`,
transformOrigin: 'top left',
color,
padding: `${verticalPadding}px ${horiPadding}px`,
textAlign: this.element.textAlign,
display: 'grid',
gridTemplateColumns: '100%',
alignItems:
this.element.textVerticalAlign === 'center'
? 'center'
: this.element.textVerticalAlign === 'bottom'
? 'end'
: 'start',
alignContent: 'center',
gap: '0',
zIndex: '1',
});
this._lastXYWH = this.element.xywh;
return html` <style>
edgeless-shape-text-editor v-text [data-v-text] {
overflow-wrap: ${autoWidth ? 'normal' : 'anywhere'};
word-break: ${autoWidth ? 'normal' : 'break-word'} !important;
white-space: ${autoWidth ? 'pre' : 'pre-wrap'} !important;
}
edgeless-shape-text-editor .inline-editor {
min-width: 1px;
}
</style>
<rich-text
.yText=${this.element.text}
.enableFormat=${false}
.enableAutoScrollHorizontally=${false}
style=${inlineEditorStyle}
></rich-text>`;
}
setKeeping(keeping: boolean) {
this._keeping = keeping;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor element!: ShapeElementModel;
@property({ attribute: false })
accessor mountEditor:
| ((
element: ShapeElementModel,
edgeless: EdgelessRootBlockComponent
) => void)
| undefined = undefined;
@query('rich-text')
accessor richText!: RichText;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-shape-text-editor': EdgelessShapeTextEditor;
}
}
@@ -0,0 +1,427 @@
import {
EdgelessCRUDIdentifier,
TextUtils,
} from '@blocksuite/affine-block-surface';
import type { RichText } from '@blocksuite/affine-components/rich-text';
import type { TextElementModel } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { getSelectedRect } from '@blocksuite/affine-shared/utils';
import {
RANGE_SYNC_EXCLUDE_ATTR,
ShadowlessElement,
} from '@blocksuite/block-std';
import {
assertExists,
Bound,
toRadian,
Vec,
WithDisposable,
} from '@blocksuite/global/utils';
import { css, html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
import { deleteElements } from '../../utils/crud.js';
export class EdgelessTextEditor extends WithDisposable(ShadowlessElement) {
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
static BORDER_WIDTH = 1;
static PADDING_HORIZONTAL = 10;
static PADDING_VERTICAL = 6;
static PLACEHOLDER_TEXT = 'Type from here';
static override styles = css`
.edgeless-text-editor {
position: absolute;
left: 0;
top: 0;
z-index: 10;
transform-origin: left top;
font-kerning: none;
border: ${EdgelessTextEditor.BORDER_WIDTH}px solid
var(--affine-primary-color, #1e96eb);
border-radius: 4px;
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
padding: ${EdgelessTextEditor.PADDING_VERTICAL}px
${EdgelessTextEditor.PADDING_HORIZONTAL}px;
overflow: visible;
}
.edgeless-text-editor .inline-editor {
white-space: pre-wrap !important;
outline: none;
}
.edgeless-text-editor .inline-editor span {
word-break: normal !important;
overflow-wrap: anywhere !important;
}
.edgeless-text-editor-placeholder {
pointer-events: none;
color: var(--affine-text-disable-color);
white-space: nowrap;
}
`;
private _isComposition = false;
private _keeping = false;
private readonly _updateRect = () => {
const edgeless = this.edgeless;
const element = this.element;
if (!edgeless || !element || !this.inlineEditorContainer) return;
const newWidth = this.inlineEditorContainer.scrollWidth;
const newHeight = this.inlineEditorContainer.scrollHeight;
const bound = new Bound(element.x, element.y, newWidth, newHeight);
const { x, y, w, h, rotate } = element;
switch (element.textAlign) {
case 'left':
{
const newPos = this.getCoordsOnLeftAlign(
{
x,
y,
w,
h,
r: toRadian(rotate),
},
newWidth,
newHeight
);
bound.x = newPos.x;
bound.y = newPos.y;
}
break;
case 'center':
{
const newPos = this.getCoordsOnCenterAlign(
{
x,
y,
w,
h,
r: toRadian(rotate),
},
newWidth,
newHeight
);
bound.x = newPos.x;
bound.y = newPos.y;
}
break;
case 'right':
{
const newPos = this.getCoordsOnRightAlign(
{
x,
y,
w,
h,
r: toRadian(rotate),
},
newWidth,
newHeight
);
bound.x = newPos.x;
bound.y = newPos.y;
}
break;
}
this.crud.updateElement(element.id, {
xywh: bound.serialize(),
});
};
get inlineEditor() {
assertExists(this.richText.inlineEditor);
return this.richText.inlineEditor;
}
get inlineEditorContainer() {
return this.inlineEditor.rootElement;
}
override connectedCallback(): void {
super.connectedCallback();
if (!this.edgeless) {
console.error('edgeless is not set.');
return;
}
if (!this.element) {
console.error('text element is not set.');
return;
}
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override firstUpdated(): void {
const edgeless = this.edgeless;
const element = this.element;
const { dispatcher } = this.edgeless;
assertExists(dispatcher);
this.updateComplete
.then(() => {
this.inlineEditor.slots.renderComplete.on(() => {
this._updateRect();
this.requestUpdate();
});
this.disposables.add(
edgeless.service.surface.elementUpdated.on(({ id }) => {
if (id === element.id) this.requestUpdate();
})
);
this.disposables.add(
edgeless.service.viewport.viewportUpdated.on(() => {
this.requestUpdate();
})
);
this.disposables.add(dispatcher.add('click', () => true));
this.disposables.add(dispatcher.add('doubleClick', () => true));
this.disposables.add(() => {
element.display = true;
if (element.text.length === 0) {
deleteElements(edgeless, [element]);
}
edgeless.service.selection.set({
elements: [],
editing: false,
});
});
if (!this.inlineEditorContainer) return;
this.disposables.addFromEvent(
this.inlineEditorContainer,
'blur',
() => !this._keeping && this.remove()
);
this.disposables.addFromEvent(
this.inlineEditorContainer,
'compositionstart',
() => {
this._isComposition = true;
this.requestUpdate();
}
);
this.disposables.addFromEvent(
this.inlineEditorContainer,
'compositionend',
() => {
this._isComposition = false;
this.requestUpdate();
}
);
element.display = false;
})
.catch(console.error);
}
getContainerOffset() {
const { PADDING_VERTICAL, PADDING_HORIZONTAL, BORDER_WIDTH } =
EdgelessTextEditor;
return `-${PADDING_HORIZONTAL + BORDER_WIDTH}px, -${
PADDING_VERTICAL + BORDER_WIDTH
}px`;
}
getCoordsOnCenterAlign(
rect: { w: number; h: number; r: number; x: number; y: number },
w1: number,
h1: number
): { x: number; y: number } {
const centerX = rect.x + rect.w / 2;
const centerY = rect.y + rect.h / 2;
let deltaXPrime = 0;
let deltaYPrime = (-rect.h / 2) * Math.cos(rect.r);
const vX = centerX + deltaXPrime;
const vY = centerY + deltaYPrime;
deltaXPrime = 0;
deltaYPrime = (-h1 / 2) * Math.cos(rect.r);
const newCenterX = vX - deltaXPrime;
const newCenterY = vY - deltaYPrime;
return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 };
}
getCoordsOnLeftAlign(
rect: { w: number; h: number; r: number; x: number; y: number },
w1: number,
h1: number
): { x: number; y: number } {
const cX = rect.x + rect.w / 2;
const cY = rect.y + rect.h / 2;
let deltaXPrime =
(-rect.w / 2) * Math.cos(rect.r) + (rect.h / 2) * Math.sin(rect.r);
let deltaYPrime =
(-rect.w / 2) * Math.sin(rect.r) - (rect.h / 2) * Math.cos(rect.r);
const vX = cX + deltaXPrime;
const vY = cY + deltaYPrime;
deltaXPrime = (-w1 / 2) * Math.cos(rect.r) + (h1 / 2) * Math.sin(rect.r);
deltaYPrime = (-w1 / 2) * Math.sin(rect.r) - (h1 / 2) * Math.cos(rect.r);
const newCenterX = vX - deltaXPrime;
const newCenterY = vY - deltaYPrime;
return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 };
}
getCoordsOnRightAlign(
rect: { w: number; h: number; r: number; x: number; y: number },
w1: number,
h1: number
): { x: number; y: number } {
const centerX = rect.x + rect.w / 2;
const centerY = rect.y + rect.h / 2;
let deltaXPrime =
(rect.w / 2) * Math.cos(rect.r) - (-rect.h / 2) * Math.sin(rect.r);
let deltaYPrime =
(rect.w / 2) * Math.sin(rect.r) + (-rect.h / 2) * Math.cos(rect.r);
const vX = centerX + deltaXPrime;
const vY = centerY + deltaYPrime;
deltaXPrime = (w1 / 2) * Math.cos(rect.r) - (-h1 / 2) * Math.sin(rect.r);
deltaYPrime = (w1 / 2) * Math.sin(rect.r) + (-h1 / 2) * Math.cos(rect.r);
const newCenterX = vX - deltaXPrime;
const newCenterY = vY - deltaYPrime;
return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 };
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.richText?.updateComplete;
return result;
}
getVisualPosition(element: TextElementModel) {
const { x, y, w, h, rotate } = element;
return Vec.rotWith([x, y], [x + w / 2, y + h / 2], toRadian(rotate));
}
override render() {
const {
text,
fontFamily,
fontSize,
fontWeight,
fontStyle,
textAlign,
rotate,
hasMaxWidth,
w,
} = this.element;
const lineHeight = TextUtils.getLineHeight(
fontFamily,
fontSize,
fontWeight
);
const rect = getSelectedRect([this.element]);
const { translateX, translateY, zoom } = this.edgeless.service.viewport;
const [visualX, visualY] = this.getVisualPosition(this.element);
const containerOffset = this.getContainerOffset();
const transformOperation = [
`translate(${translateX}px, ${translateY}px)`,
`translate(${visualX * zoom}px, ${visualY * zoom}px)`,
`scale(${zoom})`,
`rotate(${rotate}deg)`,
`translate(${containerOffset})`,
];
const isEmpty = !text.length && !this._isComposition;
const color = this.edgeless.std
.get(ThemeProvider)
.generateColorProperty(this.element.color, '#000000');
return html`<div
style=${styleMap({
transform: transformOperation.join(' '),
minWidth: hasMaxWidth ? `${rect.width}px` : 'none',
maxWidth: hasMaxWidth ? `${w}px` : 'none',
fontFamily: TextUtils.wrapFontFamily(fontFamily),
fontSize: `${fontSize}px`,
fontWeight,
fontStyle,
color,
textAlign,
lineHeight: `${lineHeight}px`,
boxSizing: 'content-box',
})}
class="edgeless-text-editor"
>
<rich-text
.yText=${text}
.enableFormat=${false}
.enableAutoScrollHorizontally=${false}
style=${isEmpty
? styleMap({
position: 'absolute',
left: 0,
top: 0,
padding: `${EdgelessTextEditor.PADDING_VERTICAL}px
${EdgelessTextEditor.PADDING_HORIZONTAL}px`,
})
: nothing}
></rich-text>
${isEmpty
? html`<span class="edgeless-text-editor-placeholder">
Type from here
</span>`
: nothing}
</div>`;
}
setKeeping(keeping: boolean) {
this._keeping = keeping;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor element!: TextElementModel;
@query('rich-text')
accessor richText!: RichText;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-text-editor': EdgelessTextEditor;
}
}
@@ -0,0 +1,88 @@
import { DefaultTheme } from '@blocksuite/affine-model';
import {
EditPropsStore,
FeatureFlagService,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import { SignalWatcher } from '@blocksuite/global/utils';
import { computed } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import type { LineWidthEvent } from '../../panel/line-width-panel.js';
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
export class EdgelessBrushMenu extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
display: flex;
position: absolute;
z-index: -1;
}
.menu-content {
display: flex;
align-items: center;
}
menu-divider {
height: 24px;
margin: 0 9px;
}
`;
private readonly _props$ = computed(() => {
const { color, lineWidth } =
this.edgeless.std.get(EditPropsStore).lastProps$.value.brush;
return {
color,
lineWidth,
};
});
private readonly _theme$ = computed(() => {
return this.edgeless.std.get(ThemeProvider).theme$.value;
});
type: GfxToolsFullOptionValue['type'] = 'brush';
override render() {
return html`
<edgeless-slide-menu>
<div class="menu-content">
<edgeless-line-width-panel
.selectedSize=${this._props$.value.lineWidth}
@select=${(e: LineWidthEvent) =>
this.onChange({ lineWidth: e.detail })}
>
</edgeless-line-width-panel>
<menu-divider .vertical=${true}></menu-divider>
<edgeless-color-panel
class="one-way"
.value=${this._props$.value.color}
.theme=${this._theme$.value}
.palettes=${DefaultTheme.StrokeColorPalettes}
.hasTransparent=${!this.edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_color_picker')}
@select=${(e: ColorEvent) =>
this.onChange({ color: e.detail.value })}
></edgeless-color-panel>
</div>
</edgeless-slide-menu>
`;
}
@property({ attribute: false })
accessor onChange!: (props: Record<string, unknown>) => void;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-brush-menu': EdgelessBrushMenu;
}
}
@@ -0,0 +1,97 @@
import {
EditPropsStore,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { SignalWatcher } from '@blocksuite/global/utils';
import { computed } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { getTooltipWithShortcut } from '../../utils.js';
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
import { EdgelessPenDarkIcon, EdgelessPenLightIcon } from './icons.js';
export class EdgelessBrushToolButton extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
display: flex;
height: 100%;
overflow-y: hidden;
}
.edgeless-brush-button {
height: 100%;
}
.pen-wrapper {
width: 35px;
height: 64px;
display: flex;
align-items: flex-end;
justify-content: center;
}
#edgeless-pen-icon {
transition: transform 0.3s ease-in-out;
transform: translateY(8px);
}
.edgeless-brush-button:hover #edgeless-pen-icon,
.pen-wrapper.active #edgeless-pen-icon {
transform: translateY(0);
}
`;
private readonly _color$ = computed(() => {
const theme = this.edgeless.std.get(ThemeProvider).theme$.value;
return this.edgeless.std
.get(ThemeProvider)
.generateColorProperty(
this.edgeless.std.get(EditPropsStore).lastProps$.value.brush.color,
undefined,
theme
);
});
override enableActiveBackground = true;
override type = 'brush' as const;
private _toggleBrushMenu() {
if (this.tryDisposePopper()) return;
!this.active && this.setEdgelessTool(this.type);
const menu = this.createPopper('edgeless-brush-menu', this);
Object.assign(menu.element, {
edgeless: this.edgeless,
onChange: (props: Record<string, unknown>) => {
this.edgeless.std.get(EditPropsStore).recordLastProps('brush', props);
this.setEdgelessTool('brush');
},
});
}
override render() {
const { active } = this;
const appTheme = this.edgeless.std.get(ThemeProvider).app$.value;
const icon =
appTheme === 'dark' ? EdgelessPenDarkIcon : EdgelessPenLightIcon;
const color = this._color$.value;
return html`
<edgeless-toolbar-button
class="edgeless-brush-button"
.tooltip=${this.popper ? '' : getTooltipWithShortcut('Pen', 'P')}
.tooltipOffset=${4}
.active=${active}
.withHover=${true}
@click=${() => this._toggleBrushMenu()}
>
<div style=${styleMap({ color })} class="pen-wrapper">${icon}</div>
</edgeless-toolbar-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-brush-tool-button': EdgelessBrushToolButton;
}
}
@@ -0,0 +1,273 @@
import { html } from 'lit';
export const EdgelessPenLightIcon = html`
<svg
width="36"
height="60"
viewBox="0 0 36 60"
fill="none"
xmlns="http://www.w3.org/2000/svg"
id="edgeless-pen-icon"
>
<g filter="url(#filter0_d_5310_64454)">
<path
d="M8 38.8965L12.2828 37.4689V106.538H8V38.8965Z"
fill="currentColor"
/>
<path
d="M8 38.8965L12.2828 37.4689V106.538H8V38.8965Z"
fill="white"
fill-opacity="0.1"
/>
<path
d="M12.2832 36.993H17.5177V106.538H12.2832V36.993Z"
fill="currentColor"
/>
<path
d="M17.5176 36.993H22.7521V106.538H17.5176V36.993Z"
fill="currentColor"
/>
<path
d="M17.5176 36.993H22.7521V106.538H17.5176V36.993Z"
fill="black"
fill-opacity="0.1"
/>
<path
d="M22.752 30.9448L27.0347 38.8965V106.538H22.752V30.9448Z"
fill="currentColor"
/>
<path
d="M22.752 30.9448L27.0347 38.8965V106.538H22.752V30.9448Z"
fill="black"
fill-opacity="0.2"
/>
<path
d="M16.5909 2.88078C16.8233 1.90625 18.2099 1.90623 18.4423 2.88075L19.896 8.97414L22.2755 18.9483L27.0345 38.8965L23.9871 38.0231C23.1982 37.7969 22.3511 37.9039 21.6431 38.3189L18.023 40.4414C17.7107 40.6245 17.3238 40.6245 17.0115 40.4414L13.0218 38.1023C12.5499 37.8256 11.9851 37.7543 11.4592 37.905L8 38.8965L12.7583 18.9483L15.1374 8.97414L16.5909 2.88078Z"
fill="#F1F1F1"
/>
<path
d="M16.5909 2.88078C16.8233 1.90625 18.2099 1.90623 18.4423 2.88075L19.896 8.97414L22.2755 18.9483L27.0345 38.8965L23.9871 38.0231C23.1982 37.7969 22.3511 37.9039 21.6431 38.3189L18.023 40.4414C17.7107 40.6245 17.3238 40.6245 17.0115 40.4414L13.0218 38.1023C12.5499 37.8256 11.9851 37.7543 11.4592 37.905L8 38.8965L12.7583 18.9483L15.1374 8.97414L16.5909 2.88078Z"
fill="url(#paint0_linear_5310_64454)"
fill-opacity="0.1"
/>
<g filter="url(#filter1_b_5310_64454)">
<path
d="M16.7391 2.26209C16.9345 1.44293 18.1 1.44293 18.2954 2.26209L20.3725 10.969H14.6621L16.7391 2.26209Z"
fill="currentColor"
/>
</g>
</g>
<defs>
<filter
id="filter0_d_5310_64454"
x="0"
y="-5"
width="35.0352"
height="124"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="4" />
<feGaussianBlur stdDeviation="4" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_5310_64454"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_5310_64454"
result="shape"
/>
</filter>
<filter
id="filter1_b_5310_64454"
x="12.7587"
y="-0.255743"
width="9.51686"
height="13.1282"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feGaussianBlur in="BackgroundImageFix" stdDeviation="0.951724" />
<feComposite
in2="SourceAlpha"
operator="in"
result="effect1_backgroundBlur_5310_64454"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_backgroundBlur_5310_64454"
result="shape"
/>
</filter>
<linearGradient
id="paint0_linear_5310_64454"
x1="22.1949"
y1="19.2552"
x2="11.0983"
y2="21.5941"
gradientUnits="userSpaceOnUse"
>
<stop />
<stop offset="0.3125" stop-opacity="0" />
<stop offset="1" stop-opacity="0" />
</linearGradient>
</defs>
</svg>
`;
export const EdgelessPenDarkIcon = html`<svg
width="34"
height="60"
viewBox="0 0 34 60"
fill="none"
xmlns="http://www.w3.org/2000/svg"
id="edgeless-pen-icon"
>
<g filter="url(#filter0_d_5310_64464)">
<path
d="M7 38.8965L11.2828 37.4689V106.538H7V38.8965Z"
fill="currentColor"
/>
<path
d="M7 38.8965L11.2828 37.4689V106.538H7V38.8965Z"
fill="black"
fill-opacity="0.1"
/>
<path
d="M11.2832 36.993H16.5177V106.538H11.2832V36.993Z"
fill="currentColor"
/>
<path
d="M11.2832 36.993H16.5177V106.538H11.2832V36.993Z"
fill="black"
fill-opacity="0.26"
/>
<path
d="M16.5176 36.993H21.7521V106.538H16.5176V36.993Z"
fill="currentColor"
/>
<path
d="M16.5176 36.993H21.7521V106.538H16.5176V36.993Z"
fill="black"
fill-opacity="0.4"
/>
<path
d="M21.752 30.9448L26.0347 38.8965V106.538H21.752V30.9448Z"
fill="currentColor"
/>
<path
d="M21.752 30.9448L26.0347 38.8965V106.538H21.752V30.9448Z"
fill="black"
fill-opacity="0.6"
/>
<path
d="M15.5909 2.88078C15.8233 1.90625 17.2099 1.90623 17.4423 2.88075L18.896 8.97414L21.2755 18.9483L26.0345 38.8965L22.9871 38.0231C22.1982 37.7969 21.3511 37.9039 20.6431 38.3189L17.023 40.4414C16.7107 40.6245 16.3238 40.6245 16.0115 40.4414L12.0218 38.1023C11.5499 37.8256 10.9851 37.7543 10.4592 37.905L7 38.8965L11.7583 18.9483L14.1374 8.97414L15.5909 2.88078Z"
fill="#C1C1C1"
/>
<path
d="M15.5909 2.88078C15.8233 1.90625 17.2099 1.90623 17.4423 2.88075L18.896 8.97414L21.2755 18.9483L26.0345 38.8965L22.9871 38.0231C22.1982 37.7969 21.3511 37.9039 20.6431 38.3189L17.023 40.4414C16.7107 40.6245 16.3238 40.6245 16.0115 40.4414L12.0218 38.1023C11.5499 37.8256 10.9851 37.7543 10.4592 37.905L7 38.8965L11.7583 18.9483L14.1374 8.97414L15.5909 2.88078Z"
fill="url(#paint0_linear_5310_64464)"
fill-opacity="0.1"
/>
<g filter="url(#filter1_b_5310_64464)">
<path
d="M15.7391 2.26209C15.9345 1.44293 17.1 1.44293 17.2954 2.26209L19.3725 10.969H13.6621L15.7391 2.26209Z"
fill="currentColor"
/>
<path
d="M15.7391 2.26209C15.9345 1.44293 17.1 1.44293 17.2954 2.26209L19.3725 10.969H13.6621L15.7391 2.26209Z"
fill="black"
fill-opacity="0.2"
/>
</g>
</g>
<defs>
<filter
id="filter0_d_5310_64464"
x="0"
y="-6"
width="33.0352"
height="122"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="2" />
<feGaussianBlur stdDeviation="3.5" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.78 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_5310_64464"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_5310_64464"
result="shape"
/>
</filter>
<filter
id="filter1_b_5310_64464"
x="11.7587"
y="-0.255743"
width="9.51686"
height="13.1282"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feGaussianBlur in="BackgroundImageFix" stdDeviation="0.951724" />
<feComposite
in2="SourceAlpha"
operator="in"
result="effect1_backgroundBlur_5310_64464"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_backgroundBlur_5310_64464"
result="shape"
/>
</filter>
<linearGradient
id="paint0_linear_5310_64464"
x1="21.1949"
y1="19.2552"
x2="11.5553"
y2="21.8444"
gradientUnits="userSpaceOnUse"
>
<stop />
<stop offset="0.302413" stop-opacity="0" />
<stop offset="0.557292" stop-opacity="0" />
<stop offset="1" stop-opacity="0" />
</linearGradient>
</defs>
</svg>`;
@@ -0,0 +1,98 @@
import { assertExists } from '@blocksuite/global/utils';
// 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;
}
) {
const duration = options?.duration ?? 230;
if (!popMap.has(reference)) popMap.set(reference, new Map());
const elMap = popMap.get(reference);
assertExists(elMap);
// if there is already a popper, cancel leave transition and apply enter transition
if (elMap.has(tagName)) {
const popper = elMap.get(tagName);
assertExists(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);
assertExists(reference.shadowRoot);
clipWrapper.append(menu);
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;
}
@@ -0,0 +1,447 @@
import {
EditPropsStore,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { assertExists, Bound } from '@blocksuite/global/utils';
import {
type ReactiveController,
type ReactiveControllerHost,
render,
} from 'lit';
import type { DraggableShape } from '../../shape/utils.js';
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>
) {
this.host = host;
host.addController(this);
}
/**
* 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<DraggableShape>).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 { service, clickThreshold = 1500 } = options;
const zoom = service.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.options.service.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;
assertExists(overlay);
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 = options.service.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) {
edgelessRect.width = edgeless.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);
}
hostConnected() {
this.host.disposables.add(
this.options.service.viewport.viewportUpdated.on(({ 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);
}
}
}
@@ -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;
@@ -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 };
};
@@ -0,0 +1,99 @@
import type { Bound, DisposableClass } from '@blocksuite/global/utils';
import type { TemplateResult } from 'lit';
import type { EdgelessRootBlockComponent } from '../../../../edgeless-root-block.js';
import type { EdgelessRootService } from '../../../../edgeless-root-service.js';
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: EdgelessRootBlockComponent;
service: EdgelessRootService;
/**
* 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;
};
@@ -0,0 +1,182 @@
import { WithDisposable } from '@blocksuite/global/utils';
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 '../context.js';
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%;
overflow-x: auto;
overscroll-behavior: none;
scrollbar-width: none;
position: relative;
height: calc(var(--menu-height) + 1px);
box-sizing: border-box;
padding: 0 10px;
scroll-snap-type: x mandatory;
}
.slide-menu-content {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
transition: left 0.5s ease-in-out;
}
.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 + 2; // border is 2
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.on(() => 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 })}
>
<div class="slide-menu-content" @wheel=${this._handleWheel}>
<slot></slot>
</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')
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;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-slide-menu': EdgelessSlideMenu;
}
}
@@ -0,0 +1,23 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { ArrowUpSmallIcon } from '@blocksuite/icons/lit';
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>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'toolbar-arrow-up-icon': ToolbarArrowUpIcon;
}
}
@@ -0,0 +1,10 @@
import type { MenuConfig } from '@blocksuite/affine-components/context-menu';
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
/**
* Helper function to build a menu configuration for a tool in dense mode
*/
export type DenseMenuBuilder = (
edgeless: EdgelessRootBlockComponent
) => MenuConfig;
@@ -0,0 +1,57 @@
import { menu } from '@blocksuite/affine-components/context-menu';
import { ConnectorMode } from '@blocksuite/affine-model';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import {
ConnectorCIcon,
ConnectorEIcon,
ConnectorLIcon,
} from '@blocksuite/icons/lit';
import type { DenseMenuBuilder } from '../common/type.js';
export const buildConnectorDenseMenu: DenseMenuBuilder = edgeless => {
const prevMode =
edgeless.std.get(EditPropsStore).lastProps$.value.connector.mode;
const isSelected = edgeless.gfx.tool.currentToolName$.peek() === 'connector';
const createSelect =
(mode: ConnectorMode, record = true) =>
() => {
edgeless.gfx.tool.setTool('connector', {
mode,
});
record &&
edgeless.std.get(EditPropsStore).recordLastProps('connector', { mode });
};
const iconSize = { width: '20', height: '20' };
return menu.subMenu({
name: 'Connector',
prefix: ConnectorCIcon(iconSize),
select: createSelect(prevMode, false),
isSelected,
options: {
items: [
menu.action({
name: 'Curve',
prefix: ConnectorCIcon(iconSize),
select: createSelect(ConnectorMode.Curve),
isSelected: isSelected && prevMode === ConnectorMode.Curve,
}),
menu.action({
name: 'Elbowed',
prefix: ConnectorEIcon(iconSize),
select: createSelect(ConnectorMode.Orthogonal),
isSelected: isSelected && prevMode === ConnectorMode.Orthogonal,
}),
menu.action({
name: 'Straight',
prefix: ConnectorLIcon(iconSize),
select: createSelect(ConnectorMode.Straight),
isSelected: isSelected && prevMode === ConnectorMode.Straight,
}),
],
},
});
};
@@ -0,0 +1,156 @@
import { ConnectorMode, DefaultTheme } from '@blocksuite/affine-model';
import {
EditPropsStore,
FeatureFlagService,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import { SignalWatcher } from '@blocksuite/global/utils';
import {
ConnectorCIcon,
ConnectorEIcon,
ConnectorLIcon,
} from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import type { LineWidthEvent } from '../../panel/line-width-panel.js';
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
function ConnectorModeButtonGroup(
mode: ConnectorMode,
setConnectorMode: (props: Record<string, unknown>) => void
) {
/**
* There is little hacky on rendering tooltip.
* We don't want either tooltip overlap the top button or tooltip on left.
* So we put the lower button's tooltip as the first element of the button group container
*/
return html`
<div class="connector-mode-button-group">
<edgeless-tool-icon-button
.active=${mode === ConnectorMode.Curve}
.activeMode=${'background'}
.tooltip=${'Curve'}
.iconSize=${'20px'}
@click=${() => setConnectorMode({ mode: ConnectorMode.Curve })}
>
${ConnectorCIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.active=${mode === ConnectorMode.Orthogonal}
.activeMode=${'background'}
.tooltip=${'Elbowed'}
.iconSize=${'20px'}
@click=${() => setConnectorMode({ mode: ConnectorMode.Orthogonal })}
>
${ConnectorEIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.active=${mode === ConnectorMode.Straight}
.activeMode=${'background'}
.tooltip=${'Straight'}
.iconSize=${'20px'}
@click=${() => setConnectorMode({ mode: ConnectorMode.Straight })}
>
${ConnectorLIcon()}
</edgeless-tool-icon-button>
</div>
`;
}
export class EdgelessConnectorMenu extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
position: absolute;
display: flex;
z-index: -1;
}
.connector-submenu-content {
display: flex;
height: 24px;
align-items: center;
justify-content: center;
}
.connector-mode-button-group {
display: flex;
justify-content: center;
align-items: center;
gap: 14px;
}
.connector-mode-button-group > edgeless-tool-icon-button svg {
fill: var(--affine-icon-color);
}
.submenu-divider {
width: 1px;
height: 24px;
margin: 0 16px;
background-color: var(--affine-border-color);
display: inline-block;
}
`;
private readonly _props$ = computed(() => {
const { mode, stroke, strokeWidth } =
this.edgeless.std.get(EditPropsStore).lastProps$.value.connector;
return { mode, stroke, strokeWidth };
});
private readonly _theme$ = computed(() => {
return this.edgeless.std.get(ThemeProvider).theme$.value;
});
override type: GfxToolsFullOptionValue['type'] = 'connector';
override render() {
const { stroke, strokeWidth, mode } = this._props$.value;
const connectorModeButtonGroup = ConnectorModeButtonGroup(
mode,
this.onChange
);
return html`
<edgeless-slide-menu>
<div class="connector-submenu-content">
${connectorModeButtonGroup}
<div class="submenu-divider"></div>
<edgeless-line-width-panel
.selectedSize=${strokeWidth}
@select=${(e: LineWidthEvent) =>
this.onChange({ strokeWidth: e.detail })}
>
</edgeless-line-width-panel>
<div class="submenu-divider"></div>
<edgeless-color-panel
class="one-way"
.value=${stroke}
.theme=${this._theme$.value}
.palettes=${DefaultTheme.StrokeColorPalettes}
.hasTransparent=${!this.edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_color_picker')}
@select=${(e: ColorEvent) =>
this.onChange({ stroke: e.detail.value })}
></edgeless-color-panel>
</div>
</edgeless-slide-menu>
`;
}
@property({ attribute: false })
accessor onChange!: (props: Record<string, unknown>) => void;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-connector-menu': EdgelessConnectorMenu;
}
}
@@ -0,0 +1,82 @@
import { ConnectorMode, getConnectorModeName } from '@blocksuite/affine-model';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import { SignalWatcher } from '@blocksuite/global/utils';
import {
ConnectorCIcon,
ConnectorEIcon,
ConnectorLIcon,
} from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { getTooltipWithShortcut } from '../../utils.js';
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
const IcomMap = {
[ConnectorMode.Straight]: ConnectorLIcon(),
[ConnectorMode.Orthogonal]: ConnectorEIcon(),
[ConnectorMode.Curve]: ConnectorCIcon(),
};
export class EdgelessConnectorToolButton extends QuickToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
display: flex;
}
`;
private readonly _mode$ = computed(() => {
return this.edgeless.std.get(EditPropsStore).lastProps$.value.connector
.mode;
});
override type = 'connector' as const;
private _toggleMenu() {
if (this.tryDisposePopper()) return;
const menu = this.createPopper('edgeless-connector-menu', this);
menu.element.edgeless = this.edgeless;
menu.element.onChange = (props: Record<string, unknown>) => {
this.edgeless.std.get(EditPropsStore).recordLastProps('connector', props);
this.setEdgelessTool(this.type, {
mode: this._mode$.value,
});
};
}
override render() {
const { active } = this;
const mode = this._mode$.value;
return html`
<edgeless-tool-icon-button
class="edgeless-connector-button"
.tooltip=${this.popper
? ''
: getTooltipWithShortcut(getConnectorModeName(mode), 'C')}
.tooltipOffset=${17}
.active=${active}
.iconContainerPadding=${6}
.iconSize=${'24px'}
@click=${() => {
// don't update tool before toggling menu
this._toggleMenu();
this.edgeless.gfx.tool.setTool('connector', {
mode,
});
}}
>
${IcomMap[mode]}
<toolbar-arrow-up-icon></toolbar-arrow-up-icon>
</edgeless-tool-icon-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-connector-tool-button': EdgelessConnectorToolButton;
}
}
@@ -0,0 +1,21 @@
import type { ColorScheme } from '@blocksuite/affine-model';
import type { Slot } from '@blocksuite/global/utils';
import { createContext } from '@lit/context';
import type { EdgelessToolbarWidget } from './edgeless-toolbar.js';
export interface EdgelessToolbarSlots {
resize: Slot<{ 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')
);
@@ -0,0 +1,105 @@
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import { HandIcon, SelectIcon } from '@blocksuite/icons/lit';
import { effect } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { query } from 'lit/decorators.js';
import { getTooltipWithShortcut } from '../../utils.js';
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
export class EdgelessDefaultToolButton extends QuickToolMixin(LitElement) {
static override styles = css`
.current-icon {
transition: 100ms;
}
.current-icon > svg {
display: block;
width: 24px;
height: 24px;
}
`;
override type: GfxToolsFullOptionValue['type'][] = ['default', 'pan'];
private _changeTool() {
if (this.toolbar.activePopper) {
// click manually always closes the popper
this.toolbar.activePopper.dispose();
}
const type = this.edgelessTool?.type;
if (type !== 'default' && type !== 'pan') {
if (localStorage.defaultTool === 'default') {
this.setEdgelessTool('default');
} else if (localStorage.defaultTool === 'pan') {
this.setEdgelessTool('pan', { panning: false });
}
return;
}
this._fadeOut();
// wait for animation to finish
setTimeout(() => {
if (type === 'default') {
this.setEdgelessTool('pan', { panning: false });
} else if (type === 'pan') {
this.setEdgelessTool('default');
}
this._fadeIn();
}, 100);
}
private _fadeIn() {
this.currentIcon.style.opacity = '1';
this.currentIcon.style.transform = `translateY(0px)`;
}
private _fadeOut() {
this.currentIcon.style.opacity = '0';
this.currentIcon.style.transform = `translateY(-5px)`;
}
override connectedCallback(): void {
super.connectedCallback();
if (!localStorage.defaultTool) {
localStorage.defaultTool = 'default';
}
this.disposables.add(
effect(() => {
const tool = this.edgeless.gfx.tool.currentToolName$.value;
if (tool === 'default' || tool === 'pan') {
localStorage.defaultTool = tool;
}
})
);
}
override render() {
const type = this.edgelessTool?.type;
const { active } = this;
return html`
<edgeless-tool-icon-button
class="edgeless-default-button ${type}"
.tooltip=${type === 'pan'
? getTooltipWithShortcut('Hand', 'H')
: getTooltipWithShortcut('Select', 'V')}
.tooltipOffset=${17}
.active=${active}
.iconContainerPadding=${6}
.iconSize=${'24px'}
@click=${this._changeTool}
>
<div class="current-icon">
${localStorage.defaultTool === 'default' ? SelectIcon() : HandIcon()}
</div>
<toolbar-arrow-up-icon></toolbar-arrow-up-icon>
</edgeless-tool-icon-button>
`;
}
@query('.current-icon')
accessor currentIcon!: HTMLInputElement;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-default-tool-button': EdgelessDefaultToolButton;
}
}
@@ -0,0 +1,696 @@
/* oxlint-disable @typescript-eslint/no-non-null-assertion */
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 { WidgetComponent } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { debounce, Slot } from '@blocksuite/global/utils';
import {
ArrowLeftSmallIcon,
ArrowRightSmallIcon,
MoreHorizontalIcon,
} from '@blocksuite/icons/lit';
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 type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
import type { MenuPopper } from './common/create-popper.js';
import {
edgelessToolbarContext,
type EdgelessToolbarSlots,
edgelessToolbarSlotsContext,
edgelessToolbarThemeContext,
} from './context.js';
import { getQuickTools, getSeniorTools } from './tools.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,
EdgelessRootBlockComponent
> {
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;
}
.edgeless-toolbar-wrapper[data-app-theme='light'] {
${unsafeCSS(lightToolbarStyles.join('\n'))}
}
.edgeless-toolbar-wrapper[data-app-theme='dark'] {
${unsafeCSS(darkToolbarStyles.join('\n'))}
}
.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);
}
.brush-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.emit({ 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);
private _resizeObserver: ResizeObserver | null = null;
private readonly _slotsProvider = new ContextProvider(this, {
context: edgelessToolbarSlotsContext,
initialValue: { resize: new Slot() } 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() {
return getQuickTools({ edgeless: this.block });
}
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() {
return getSeniorTools({
edgeless: this.block,
toolbarContainer: this.toolbarContainer,
});
}
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.currentToolOption$.value;
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get isPresentMode() {
return this.edgelessTool.type === '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?.type)}
>
${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))
);
this._disposables.add(
this.block.bindHotKey(
{
Escape: () => {
if (this.gfx.selection.editing) return;
if (this.edgelessTool.type === 'frameNavigator') return;
if (this.edgelessTool.type === 'default') {
if (this.activePopper) {
this.activePopper.dispose();
this.activePopper = null;
}
return;
}
this.gfx.tool.setTool('default');
},
},
{ global: true }
)
);
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
}
override firstUpdated() {
const { _disposables, block, gfx } = this;
_disposables.add(
gfx.viewport.viewportUpdated.on(() => this.requestUpdate())
);
_disposables.add(
block.slots.readonlyUpdated.on(() => {
this.requestUpdate();
})
);
_disposables.add(
block.slots.toolbarLocked.on(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(
this.std.get(EditPropsStore).slots.storageUpdated.on(({ key }) => {
if (key === 'presentHideToolbar') {
this.requestUpdate();
}
})
);
}
override render() {
const { type } = this.edgelessTool || {};
if (this.doc.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;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-toolbar-widget': EdgelessToolbarWidget;
}
}
@@ -0,0 +1,78 @@
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import { css, html, LitElement } from 'lit';
import { getTooltipWithShortcut } from '../../utils.js';
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
import { EdgelessEraserDarkIcon, EdgelessEraserLightIcon } from './icons.js';
export class EdgelessEraserToolButton extends EdgelessToolbarToolMixin(
LitElement
) {
static override styles = css`
:host {
height: 100%;
overflow-y: hidden;
}
.eraser-button {
display: flex;
justify-content: center;
align-items: flex-end;
position: relative;
width: 49px;
height: 64px;
}
#edgeless-eraser-icon {
transition: transform 0.3s ease-in-out;
transform: translateY(8px);
}
.eraser-button:hover #edgeless-eraser-icon,
.eraser-button.active #edgeless-eraser-icon {
transform: translateY(0);
}
`;
override enableActiveBackground = true;
override type: GfxToolsFullOptionValue['type'] = 'eraser';
override firstUpdated() {
this.disposables.add(
this.edgeless.bindHotKey(
{
Escape: () => {
if (this.edgelessTool.type === 'eraser') {
this.setEdgelessTool({ type: 'default' });
}
},
},
{ global: true }
)
);
}
override render() {
const type = this.edgelessTool?.type;
const appTheme = this.edgeless.std.get(ThemeProvider).app$.value;
const icon =
appTheme === 'dark' ? EdgelessEraserDarkIcon : EdgelessEraserLightIcon;
return html`
<edgeless-toolbar-button
class="edgeless-eraser-button"
.tooltip=${getTooltipWithShortcut('Eraser', 'E')}
.tooltipOffset=${4}
.active=${type === 'eraser'}
@click=${() => this.setEdgelessTool({ type: 'eraser' })}
>
<div class="eraser-button">${icon}</div>
</edgeless-toolbar-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-eraser-tool-button': EdgelessEraserToolButton;
}
}
@@ -0,0 +1,422 @@
import { html } from 'lit';
export const EdgelessEraserLightIcon = html`<svg
width="44"
height="49"
viewBox="0 0 44 49"
fill="none"
xmlns="http://www.w3.org/2000/svg"
id="edgeless-eraser-icon"
>
<g filter="url(#filter0_d_5310_64451)">
<rect x="6" y="2" width="32" height="59" rx="5.75" fill="#F1F1F1" />
<rect x="6.5" y="2.5" width="31" height="58" rx="5.25" stroke="#E3E2E4" />
</g>
<g filter="url(#filter1_f_5310_64451)">
<rect
x="36.2002"
y="4.44995"
width="18.4"
height="3.45"
rx="1.725"
transform="rotate(90 36.2002 4.44995)"
fill="white"
fill-opacity="0.78"
/>
</g>
<g filter="url(#filter2_f_5310_64451)">
<path
d="M4 60H40V22C40 18.6863 37.3137 16 34 16H10C6.68629 16 4 18.6863 4 22V60Z"
fill="#343434"
fill-opacity="0.22"
/>
</g>
<path
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
fill="#173654"
/>
<path
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
fill="url(#paint0_linear_5310_64451)"
fill-opacity="0.2"
/>
<path
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
stroke="#E7E7E7"
/>
<path
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
fill="#1E96EB"
/>
<path
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
fill="url(#paint1_linear_5310_64451)"
fill-opacity="0.2"
/>
<path
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
stroke="#E7E7E7"
/>
<rect
x="-0.5"
y="0.5"
width="11"
height="45"
transform="matrix(-1 0 0 1 27 16)"
fill="#EFFAFF"
/>
<rect
x="-0.5"
y="0.5"
width="11"
height="45"
transform="matrix(-1 0 0 1 27 16)"
fill="url(#paint2_linear_5310_64451)"
fill-opacity="0.2"
/>
<rect
x="-0.5"
y="0.5"
width="11"
height="45"
transform="matrix(-1 0 0 1 27 16)"
stroke="#E7E7E7"
/>
<defs>
<filter
id="filter0_d_5310_64451"
x="2"
y="2"
width="40"
height="67"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="4" />
<feGaussianBlur stdDeviation="2" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_5310_64451"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_5310_64451"
result="shape"
/>
</filter>
<filter
id="filter1_f_5310_64451"
x="30.45"
y="2.14995"
width="8.0502"
height="23"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="1.15"
result="effect1_foregroundBlur_5310_64451"
/>
</filter>
<filter
id="filter2_f_5310_64451"
x="0"
y="12"
width="44"
height="52"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="2"
result="effect1_foregroundBlur_5310_64451"
/>
</filter>
<linearGradient
id="paint0_linear_5310_64451"
x1="11"
y1="16"
x2="11"
y2="62"
gradientUnits="userSpaceOnUse"
>
<stop stop-opacity="0" />
<stop offset="1" />
</linearGradient>
<linearGradient
id="paint1_linear_5310_64451"
x1="33"
y1="16"
x2="33"
y2="62"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="white" />
<stop offset="1" stop-opacity="0" />
</linearGradient>
<linearGradient
id="paint2_linear_5310_64451"
x1="6"
y1="0"
x2="6"
y2="46"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FFF8F8" stop-opacity="0" />
<stop offset="1" />
</linearGradient>
</defs>
</svg>`;
export const EdgelessEraserDarkIcon = html`<svg
width="44"
height="49"
viewBox="0 0 44 49"
fill="none"
xmlns="http://www.w3.org/2000/svg"
id="edgeless-eraser-icon"
>
<g filter="url(#filter0_d_5310_64471)">
<path
d="M6 7.75C6 4.57436 8.57436 2 11.75 2H32.25C35.4256 2 38 4.57436 38 7.75V61H6V7.75Z"
fill="#C1C1C1"
/>
<path
d="M6.5 7.75C6.5 4.85051 8.85051 2.5 11.75 2.5H32.25C35.1495 2.5 37.5 4.85051 37.5 7.75V60.5H6.5V7.75Z"
stroke="#DDDDDD"
/>
<path
d="M6.5 7.75C6.5 4.85051 8.85051 2.5 11.75 2.5H32.25C35.1495 2.5 37.5 4.85051 37.5 7.75V60.5H6.5V7.75Z"
stroke="black"
stroke-opacity="0.3"
/>
</g>
<g filter="url(#filter1_f_5310_64471)">
<rect
x="36.2002"
y="4.44995"
width="18.4"
height="3.45"
rx="1.725"
transform="rotate(90 36.2002 4.44995)"
fill="white"
fill-opacity="0.38"
/>
</g>
<g filter="url(#filter2_f_5310_64471)">
<path
d="M4 62H40V23C40 19.6863 37.3137 17 34 17H10C6.68629 17 4 19.6863 4 23V62Z"
fill="black"
fill-opacity="0.44"
/>
</g>
<path
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
fill="#0D2338"
/>
<path
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
fill="url(#paint0_linear_5310_64471)"
fill-opacity="0.2"
/>
<path
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
stroke="#D3D3D3"
/>
<path
d="M16.5 16.5H11H9.37046C8.79521 16.5 8.29403 16.8921 8.15566 17.4505L8.14174 17.5067C7.93847 18.3269 7.27583 18.9534 6.44553 19.1105C5.89708 19.2142 5.5 19.6934 5.5 20.2516V21.75V27.5V39V61.5H16.5V16.5Z"
stroke="black"
stroke-opacity="0.4"
/>
<path
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
fill="#1A7CC1"
/>
<path
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
fill="url(#paint1_linear_5310_64471)"
fill-opacity="0.2"
/>
<path
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
stroke="#D3D3D3"
/>
<path
d="M27.5 16.5H33H34.6295C35.2048 16.5 35.706 16.8921 35.8443 17.4505L35.8583 17.5067C36.0615 18.3269 36.7242 18.9534 37.5545 19.1105C38.1029 19.2142 38.5 19.6934 38.5 20.2516V21.75V27.5V39V61.5H27.5V16.5Z"
stroke="black"
stroke-opacity="0.4"
/>
<rect
x="-0.5"
y="0.5"
width="11"
height="45"
transform="matrix(-1 0 0 1 27 16)"
fill="#D0DFE5"
/>
<rect
x="-0.5"
y="0.5"
width="11"
height="45"
transform="matrix(-1 0 0 1 27 16)"
fill="url(#paint2_linear_5310_64471)"
fill-opacity="0.2"
/>
<rect
x="-0.5"
y="0.5"
width="11"
height="45"
transform="matrix(-1 0 0 1 27 16)"
stroke="#D3D3D3"
/>
<rect
x="-0.5"
y="0.5"
width="11"
height="45"
transform="matrix(-1 0 0 1 27 16)"
stroke="black"
stroke-opacity="0.4"
/>
<defs>
<filter
id="filter0_d_5310_64471"
x="2"
y="0"
width="40"
height="67"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="2" />
<feGaussianBlur stdDeviation="2" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.195003 0 0 0 0 0.0133398 0 0 0 0 0.0133398 0 0 0 0.66 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_5310_64471"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_5310_64471"
result="shape"
/>
</filter>
<filter
id="filter1_f_5310_64471"
x="30.45"
y="2.14995"
width="8.0502"
height="23"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="1.15"
result="effect1_foregroundBlur_5310_64471"
/>
</filter>
<filter
id="filter2_f_5310_64471"
x="0"
y="13"
width="44"
height="53"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="2"
result="effect1_foregroundBlur_5310_64471"
/>
</filter>
<linearGradient
id="paint0_linear_5310_64471"
x1="11"
y1="16"
x2="11"
y2="62"
gradientUnits="userSpaceOnUse"
>
<stop stop-opacity="0" />
<stop offset="1" />
</linearGradient>
<linearGradient
id="paint1_linear_5310_64471"
x1="33"
y1="16"
x2="33"
y2="62"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="white" />
<stop offset="1" stop-opacity="0" />
</linearGradient>
<linearGradient
id="paint2_linear_5310_64471"
x1="6"
y1="0"
x2="6"
y2="46"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FFF8F8" stop-opacity="0" />
<stop offset="1" />
</linearGradient>
</defs>
</svg>`;
@@ -0,0 +1,9 @@
export const FrameConfig: {
name: string;
wh: [number, number];
}[] = [
{ name: '1:1', wh: [1200, 1200] },
{ name: '4:3', wh: [1600, 1200] },
{ name: '16:9', wh: [1600, 900] },
{ name: '2:1', wh: [1600, 800] },
];
@@ -0,0 +1,30 @@
import { menu } from '@blocksuite/affine-components/context-menu';
import { FrameIcon } from '@blocksuite/icons/lit';
import type { DenseMenuBuilder } from '../common/type.js';
import { FrameConfig } from './config.js';
export const buildFrameDenseMenu: DenseMenuBuilder = edgeless =>
menu.subMenu({
name: 'Frame',
prefix: FrameIcon({ width: '20px', height: '20px' }),
select: () => edgeless.gfx.tool.setTool({ type: 'frame' }),
isSelected: edgeless.gfx.tool.currentToolName$.peek() === 'frame',
options: {
items: [
menu.action({
name: 'Custom',
select: () => edgeless.gfx.tool.setTool({ type: 'frame' }),
}),
...FrameConfig.map(config =>
menu.action({
name: `Slide ${config.name}`,
select: () => {
edgeless.gfx.tool.setTool('default');
edgeless.service.frame.createFrameOnViewportCenter(config.wh);
},
})
),
],
},
});
@@ -0,0 +1,104 @@
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import { css, html, LitElement } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
import { FrameConfig } from './config.js';
export class EdgelessFrameMenu extends EdgelessToolbarToolMixin(LitElement) {
static override styles = css`
:host {
position: absolute;
display: flex;
z-index: -1;
}
.menu-content {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
}
.frame-add-button {
width: 40px;
height: 24px;
border-radius: 4px;
border: 1px solid var(--affine-border-color);
color: var(--affine-text-primary-color);
line-height: 20px;
font-weight: 400;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
}
.frame-add-button::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
border-radius: 3px;
background: transparent;
transition: background-color 0.23s ease;
pointer-events: none;
}
.frame-add-button:hover::before {
background: var(--affine-hover-color);
}
.custom {
width: 60px;
background: var(--affine-hover-color);
}
.divider {
width: 1px;
height: 20px;
background: var(--affine-border-color);
transform: scaleX(0.5);
}
`;
override type: GfxToolsFullOptionValue['type'] = 'frame';
override render() {
const { edgeless } = this;
return html`
<edgeless-slide-menu .showNext=${false}>
<div class="menu-content">
<div class="frame-add-button custom">Custom</div>
<div class="divider"></div>
${repeat(
FrameConfig,
item => item.name,
(item, index) => html`
<div
@click=${() => {
edgeless.gfx.tool.setTool('default');
edgeless.service.frame.createFrameOnViewportCenter(item.wh);
}}
class="frame-add-button ${index}"
data-name="${item.name}"
data-w="${item.wh[0]}"
data-h="${item.wh[1]}"
>
${item.name}
</div>
`
)}
</div>
</edgeless-slide-menu>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-frame-menu': EdgelessFrameMenu;
}
}
@@ -0,0 +1,51 @@
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import { FrameIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { getTooltipWithShortcut } from '../../../components/utils.js';
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
export class EdgelessFrameToolButton extends QuickToolMixin(LitElement) {
static override styles = css`
:host {
display: flex;
}
`;
override type: GfxToolsFullOptionValue['type'] = 'frame';
private _toggleFrameMenu() {
if (this.tryDisposePopper()) return;
const menu = this.createPopper('edgeless-frame-menu', this);
menu.element.edgeless = this.edgeless;
}
override render() {
const type = this.edgelessTool?.type;
return html`
<edgeless-tool-icon-button
class="edgeless-frame-button"
.tooltip=${this.popper ? '' : getTooltipWithShortcut('Frame', 'F')}
.tooltipOffset=${17}
.iconSize=${'24px'}
.active=${type === 'frame'}
.iconContainerPadding=${6}
@click=${() => {
// don't update tool before toggling menu
this._toggleFrameMenu();
this.setEdgelessTool({ type: 'frame' });
}}
>
${FrameIcon()}
<toolbar-arrow-up-icon></toolbar-arrow-up-icon>
</edgeless-tool-icon-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-frame-tool-button': EdgelessFrameToolButton;
}
}
@@ -0,0 +1,71 @@
import { html } from 'lit';
export const LassoFreeHandIcon = html`
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.03152 18.9667C8.07902 18.9667 8.92819 18.1175 8.92819 17.07C8.92819 16.0225 8.07902 15.1733 7.03152 15.1733C5.98402 15.1733 5.13485 16.0225 5.13485 17.07C5.13485 18.1175 5.98402 18.9667 7.03152 18.9667ZM7.03152 20.2667C8.79699 20.2667 10.2282 18.8355 10.2282 17.07C10.2282 15.3045 8.79699 13.8733 7.03152 13.8733C5.26605 13.8733 3.83485 15.3045 3.83485 17.07C3.83485 18.8355 5.26605 20.2667 7.03152 20.2667Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.75714 19.6175C4.75714 19.3949 4.81661 19.145 4.93113 18.856C5.02405 18.6215 5.30218 18.5315 5.52776 18.6442C5.75334 18.757 5.83848 19.0293 5.75223 19.2663C5.68785 19.4432 5.67047 19.5529 5.67047 19.6175C5.67047 19.6727 5.67683 19.6824 5.71909 19.7205C5.74837 19.7468 5.77881 19.7695 5.82813 19.8062C5.8416 19.8162 5.85647 19.8273 5.87312 19.8398C5.94101 19.8907 6.03269 19.9615 6.119 20.0543C6.31218 20.2619 6.44109 20.5388 6.44109 20.9276C6.44109 21.3166 6.3093 21.6272 6.02789 21.8443C5.80114 22.0192 5.50339 22.1052 5.27045 22.1724C5.26524 22.1739 5.26006 22.1754 5.25492 22.1769C5.24264 22.1804 5.23031 22.184 5.21794 22.1876C4.8171 22.3032 4.37177 22.4316 3.91148 22.7773C3.70983 22.9288 3.42276 22.9326 3.24443 22.7542C3.06609 22.5759 3.06453 22.2842 3.26124 22.1263C3.89506 21.6177 4.50962 21.4409 4.94956 21.3144C4.96714 21.3093 4.98443 21.3044 5.00143 21.2994C5.28007 21.2189 5.40217 21.1735 5.47003 21.1211C5.471 21.1204 5.47202 21.1196 5.47311 21.1188C5.49238 21.1045 5.52776 21.0781 5.52776 20.9276C5.52776 20.7684 5.48543 20.7141 5.45027 20.6763C5.42241 20.6464 5.38566 20.6159 5.32511 20.5705C5.31683 20.5642 5.30753 20.5574 5.29743 20.5499C5.24552 20.5114 5.17247 20.4573 5.1081 20.3994C4.92203 20.2319 4.75714 19.9905 4.75714 19.6175Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12.2086 12.6149C11.9968 11.7531 12.903 11.0458 13.6876 11.4606L22.0945 15.905C22.9737 16.3698 22.7465 17.6876 21.7625 17.8313L17.8897 18.3968C17.8383 18.4043 17.7946 18.4384 17.7749 18.4867L16.3978 21.8656C16.0219 22.7881 14.685 22.6903 14.4473 21.7229L12.2086 12.6149ZM13.2757 12.2397C13.1636 12.1804 13.0342 12.2815 13.0644 12.4046L15.3031 21.5126C15.3371 21.6508 15.528 21.6648 15.5817 21.533L16.9588 18.1541C17.0963 17.8167 17.402 17.5773 17.7624 17.5247L21.6352 16.9593C21.7758 16.9388 21.8082 16.7505 21.6826 16.6841L13.2757 12.2397Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M19.1489 14.9806C20.1975 13.7474 20.8015 12.2466 20.8015 10.6767C20.8015 6.775 17.0709 3.3 12.0549 3.3C7.03878 3.3 3.30818 6.775 3.30818 10.6767C3.30818 12.1636 3.85 13.5886 4.7983 14.7828C4.47298 15.1005 4.21518 15.4871 4.04843 15.9189C2.76805 14.4626 2.00818 12.6463 2.00818 10.6767C2.00818 5.88468 6.50623 2 12.0549 2C17.6035 2 22.1015 5.88468 22.1015 10.6767C22.1015 12.4784 21.4656 14.1519 20.3771 15.5389L19.1489 14.9806ZM13.9695 17.8785C13.3575 17.9927 12.7174 18.0534 12.0549 18.0534C11.3871 18.0534 10.7422 17.9918 10.1258 17.8758C10.0103 18.3206 9.80141 18.7278 9.52133 19.0751C10.3307 19.2567 11.1797 19.3533 12.0549 19.3533C12.8212 19.3533 13.5675 19.2793 14.2846 19.1389L13.9695 17.8785Z"
fill="currentColor"
/>
</svg>
`;
export const LassoPolygonalIcon = html`
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12.1538 12.3949C11.9419 11.533 12.8482 10.8257 13.6328 11.2405L22.0397 15.6849C22.9188 16.1497 22.6917 17.4676 21.7077 17.6112L17.8349 18.1767C17.7834 18.1842 17.7397 18.2184 17.7201 18.2666L16.343 21.6455C15.967 22.568 14.6302 22.4703 14.3924 21.5029L12.1538 12.3949ZM13.2209 12.0196C13.1088 11.9604 12.9793 12.0614 13.0096 12.1845L15.2482 21.2926C15.2822 21.4308 15.4732 21.4447 15.5269 21.3129L16.9039 17.934C17.0414 17.5967 17.3471 17.3573 17.7076 17.3047L21.5804 16.7393C21.7209 16.7187 21.7534 16.5304 21.6278 16.4641L13.2209 12.0196Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.11 18.9667C10.1575 18.9667 11.0067 18.1175 11.0067 17.07C11.0067 16.0225 10.1575 15.1733 9.11 15.1733C8.0625 15.1733 7.21333 16.0225 7.21333 17.07C7.21333 18.1175 8.0625 18.9667 9.11 18.9667ZM9.11 20.2667C10.8755 20.2667 12.3067 18.8355 12.3067 17.07C12.3067 15.3045 10.8755 13.8733 9.11 13.8733C7.34453 13.8733 5.91333 15.3045 5.91333 17.07C5.91333 18.8355 7.34453 20.2667 9.11 20.2667Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M20.15 1.78546V12.8479L18.2895 14.0882L17.0275 13.3671L18.85 12.1521V4.21453L10.565 9.7379L3.78505 6.77169L7.24943 14.4703C6.85977 14.7497 6.5355 15.1145 6.30404 15.5374L1.21497 4.22831L10.4351 8.26209L20.15 1.78546ZM12.0942 18.2184L13.8336 17.0588L13.443 15.7568L12.2635 16.5431C12.2919 16.7146 12.3067 16.8905 12.3067 17.07C12.3067 17.4748 12.2315 17.862 12.0942 18.2184Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.83562 19.6175C6.83562 19.3949 6.89509 19.145 7.00962 18.856C7.10254 18.6215 7.38066 18.5314 7.60625 18.6442C7.83183 18.757 7.91697 19.0293 7.83072 19.2663C7.76634 19.4432 7.74896 19.5529 7.74896 19.6175C7.74896 19.6726 7.75531 19.6824 7.79757 19.7204C7.82686 19.7468 7.8573 19.7695 7.90662 19.8062C7.92009 19.8162 7.93496 19.8273 7.9516 19.8398C8.0195 19.8907 8.11118 19.9615 8.19749 20.0542C8.39066 20.2619 8.51958 20.5387 8.51958 20.9276C8.51958 21.3165 8.38779 21.6272 8.10638 21.8443C7.87962 22.0192 7.58188 22.1051 7.34894 22.1724C7.34373 22.1739 7.33855 22.1754 7.33341 22.1769C7.32112 22.1804 7.3088 22.184 7.29643 22.1875C6.89559 22.3031 6.45026 22.4316 5.98997 22.7773C5.78832 22.9288 5.50125 22.9326 5.32291 22.7542C5.14457 22.5759 5.14302 22.2841 5.33973 22.1263C5.97355 21.6177 6.58811 21.4409 7.02805 21.3144C7.04562 21.3093 7.06292 21.3043 7.07992 21.2994C7.35856 21.2189 7.48065 21.1735 7.54851 21.1211C7.54948 21.1204 7.55051 21.1196 7.55159 21.1188C7.57087 21.1044 7.60625 21.0781 7.60625 20.9276C7.60625 20.7684 7.56391 20.7141 7.52876 20.6763C7.5009 20.6464 7.46415 20.6158 7.4036 20.5704C7.39532 20.5642 7.38602 20.5573 7.37592 20.5498C7.324 20.5114 7.25095 20.4573 7.18659 20.3993C7.00052 20.2319 6.83562 19.9905 6.83562 19.6175Z"
fill="currentColor"
/>
</svg>
`;
@@ -0,0 +1,41 @@
import { menu } from '@blocksuite/affine-components/context-menu';
import { LassoMode } from '@blocksuite/affine-shared/types';
import type { DenseMenuBuilder } from '../common/type.js';
import { LassoFreeHandIcon, LassoPolygonalIcon } from './icons.js';
export const buildLassoDenseMenu: DenseMenuBuilder = edgeless => {
// TODO: active state
// const prevMode =
// edgeless.service.editPropsStore.getLastProps('lasso').mode ??
// LassoMode.FreeHand;
const isActive = edgeless.gfx.tool.currentToolName$.peek() === 'lasso';
const createSelect = (mode: LassoMode) => () => {
edgeless.gfx.tool.setTool('lasso', { mode });
};
return menu.subMenu({
name: 'Lasso',
prefix: LassoFreeHandIcon,
select: createSelect(LassoMode.FreeHand),
isSelected: isActive,
options: {
items: [
menu.action({
prefix: LassoFreeHandIcon,
name: 'Free',
select: createSelect(LassoMode.FreeHand),
// isSelected: isActive && prevMode === LassoMode.FreeHand,
}),
menu.action({
prefix: LassoPolygonalIcon,
name: 'Polygonal',
select: createSelect(LassoMode.Polygonal),
// isSelected: isActive && prevMode === LassoMode.Polygonal,
}),
],
},
});
};
@@ -0,0 +1,103 @@
import { LassoMode } from '@blocksuite/affine-shared/types';
import { WithDisposable } from '@blocksuite/global/utils';
import { effect } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { query, state } from 'lit/decorators.js';
import { getTooltipWithShortcut } from '../../utils.js';
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
import { LassoFreeHandIcon, LassoPolygonalIcon } from './icons.js';
export class EdgelessLassoToolButton extends QuickToolMixin(
WithDisposable(LitElement)
) {
static override styles = css`
.current-icon {
transition: 100ms;
width: 24px;
height: 24px;
}
.current-icon > svg {
display: block;
}
`;
private readonly _changeTool = () => {
const tool = this.edgelessTool;
if (tool.type !== 'lasso') {
this.setEdgelessTool({ type: 'lasso', mode: this.curMode });
return;
}
this._fadeOut();
setTimeout(() => {
this.curMode === LassoMode.FreeHand
? this.setEdgelessTool({ type: 'lasso', mode: LassoMode.Polygonal })
: this.setEdgelessTool({ type: 'lasso', mode: LassoMode.FreeHand });
this._fadeIn();
}, 100);
};
override type = 'lasso' as const;
private _fadeIn() {
this.currentIcon.style.opacity = '1';
this.currentIcon.style.transform = `translateY(0px)`;
}
private _fadeOut() {
this.currentIcon.style.opacity = '0';
this.currentIcon.style.transform = `translateY(-5px)`;
}
override connectedCallback(): void {
super.connectedCallback();
this.disposables.add(
effect(() => {
const tool = this.edgeless.gfx.tool.currentToolOption$.value;
if (tool?.type === 'lasso') {
const { mode } = tool;
this.curMode = mode;
}
})
);
}
override render() {
const type = this.edgelessTool?.type;
const mode = this.curMode === LassoMode.FreeHand ? 'freehand' : 'polygonal';
return html`
<edgeless-tool-icon-button
class="edgeless-lasso-button ${mode}"
.tooltip=${getTooltipWithShortcut('Lasso', 'L')}
.tooltipOffset=${17}
.active=${type === 'lasso'}
.iconContainerPadding=${6}
.iconSize=${'24px'}
@click=${this._changeTool}
>
<span class="current-icon">
${this.curMode === LassoMode.FreeHand
? LassoFreeHandIcon
: LassoPolygonalIcon}
</span>
<toolbar-arrow-up-icon></toolbar-arrow-up-icon>
</edgeless-tool-icon-button>
`;
}
@state()
accessor curMode: LassoMode = LassoMode.FreeHand;
@query('.current-icon')
accessor currentIcon!: HTMLInputElement;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-lasso-tool-button': EdgelessLassoToolButton;
}
}
@@ -0,0 +1,33 @@
import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmark';
import { menu } from '@blocksuite/affine-components/context-menu';
import { LinkIcon } from '@blocksuite/affine-components/icons';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type { DenseMenuBuilder } from '../common/type.js';
export const buildLinkDenseMenu: DenseMenuBuilder = edgeless =>
menu.action({
name: 'Link',
prefix: LinkIcon,
select: () => {
const [_, { insertedLinkType }] = edgeless.std.command.exec(
insertLinkByQuickSearchCommand
);
insertedLinkType
?.then(type => {
const flavour = type?.flavour;
if (!flavour) return;
edgeless.std
.getOptional(TelemetryProvider)
?.track('CanvasElementAdded', {
control: 'toolbar:general',
page: 'whiteboard editor',
module: 'toolbar',
type: flavour.split(':')[1],
});
})
.catch(console.error);
},
});
@@ -0,0 +1,70 @@
import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmark';
import { LinkIcon } from '@blocksuite/affine-components/icons';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import { css, html, LitElement } from 'lit';
import { getTooltipWithShortcut } from '../../utils.js';
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
export class EdgelessLinkToolButton extends QuickToolMixin(LitElement) {
static override styles = css`
.link-icon,
.link-icon > svg {
width: 24px;
height: 24px;
}
`;
override type = 'default' as const;
private _onClick() {
const [_, { insertedLinkType }] = this.edgeless.std.command.exec(
insertLinkByQuickSearchCommand
);
insertedLinkType
?.then(type => {
const flavour = type?.flavour;
if (!flavour) return;
this.edgeless.std
.getOptional(TelemetryProvider)
?.track('CanvasElementAdded', {
control: 'toolbar:general',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: flavour.split(':')[1],
});
this.edgeless.std
.getOptional(TelemetryProvider)
?.track('LinkedDocCreated', {
control: 'links',
page: 'whiteboard editor',
module: 'edgeless toolbar',
segment: 'whiteboard',
type: flavour.split(':')[1],
other: 'existing doc',
});
})
.catch(console.error);
}
override render() {
return html`<edgeless-tool-icon-button
.iconContainerPadding="${6}"
.tooltip="${getTooltipWithShortcut('Link', '@')}"
.tooltipOffset=${17}
class="edgeless-link-tool-button"
@click=${this._onClick}
>
<span class="link-icon">${LinkIcon}</span>
</edgeless-tool-icon-button>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-link-tool-button': EdgelessLinkToolButton;
}
}
@@ -0,0 +1,46 @@
import { ColorScheme, MindmapStyle } from '@blocksuite/affine-model';
import type { TemplateResult } from 'lit';
import { type DraggableTool, getMindmapRender } from './basket-elements.js';
import {
mindMapStyle1Dark,
mindMapStyle1Light,
mindMapStyle2Dark,
mindMapStyle2Light,
mindMapStyle3,
mindMapStyle4,
} from './icons.js';
export type ToolbarMindmapItem = {
type: 'mindmap';
icon: TemplateResult;
style: MindmapStyle;
render: DraggableTool['render'];
};
export const getMindMaps = (theme: ColorScheme): ToolbarMindmapItem[] => [
{
type: 'mindmap',
icon: theme === ColorScheme.Dark ? mindMapStyle1Dark : mindMapStyle1Light,
style: MindmapStyle.ONE,
render: getMindmapRender(MindmapStyle.ONE),
},
{
type: 'mindmap',
icon: mindMapStyle4,
style: MindmapStyle.FOUR,
render: getMindmapRender(MindmapStyle.FOUR),
},
{
type: 'mindmap',
icon: mindMapStyle3,
style: MindmapStyle.THREE,
render: getMindmapRender(MindmapStyle.THREE),
},
{
type: 'mindmap',
icon: theme === 'light' ? mindMapStyle2Light : mindMapStyle2Dark,
style: MindmapStyle.TWO,
render: getMindmapRender(MindmapStyle.TWO),
},
];
@@ -0,0 +1,214 @@
import { addAttachments } from '@blocksuite/affine-block-attachment';
import { insertEdgelessTextCommand } from '@blocksuite/affine-block-edgeless-text';
import { addImages } from '@blocksuite/affine-block-image';
import { CanvasElementType } from '@blocksuite/affine-block-surface';
import {
MAX_IMAGE_WIDTH,
type MindmapStyle,
TextElementModel,
} from '@blocksuite/affine-model';
import {
FeatureFlagService,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { openFileOrFiles } from '@blocksuite/affine-shared/utils';
import { assertInstanceOf, Bound } from '@blocksuite/global/utils';
import type { TemplateResult } from 'lit';
import * as Y from 'yjs';
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
import type { EdgelessRootService } from '../../../edgeless-root-service.js';
import { mountTextElementEditor } from '../../../utils/text.js';
export type ConfigProperty = 'x' | 'y' | 'r' | 's' | 'z' | 'o';
export type ConfigState = 'default' | 'active' | 'hover' | 'next';
export type ConfigStyle = Partial<Record<ConfigProperty, number | string>>;
export type ToolConfig = Record<ConfigState, ConfigStyle>;
export type DraggableTool = {
name: 'text' | 'mindmap' | 'media';
icon: TemplateResult;
config: ToolConfig;
standardWidth?: number;
render: (
bound: Bound,
edgelessService: EdgelessRootService,
edgeless: EdgelessRootBlockComponent
) => Promise<string | null>;
};
const unitMap = { x: 'px', y: 'px', r: 'deg', s: '', z: '', o: '' };
export const textConfig: ToolConfig = {
default: { x: -20, y: -8, r: 7.74, s: 0.92, z: 3 },
active: { x: -22, y: -9, r: -8, s: 0.92 },
hover: { x: -22, y: -9, r: -8, s: 1, z: 3 },
next: { x: -22, y: 64, r: 0 },
};
export const mindmapConfig: ToolConfig = {
default: { x: 4, y: -4, s: 1, r: -7, z: 2 },
active: { x: 11, y: -14, r: 9, s: 1 },
hover: { x: 11, y: -14, r: 9, s: 1.16, z: 3 },
next: { y: 64, r: 0 },
};
export const mediaConfig: ToolConfig = {
default: { x: -20, y: -15, r: 23, s: 1.2, z: 1 },
active: { x: -25, y: -20, r: -9, s: 1.2 },
hover: { x: -25, y: -20, r: -9, s: 1.5, z: 3 },
next: { y: 64, r: 0 },
};
export const getMindmapRender =
(mindmapStyle: MindmapStyle): DraggableTool['render'] =>
async (bound, edgelessService) => {
const [x, y, _, h] = bound.toXYWH();
const rootW = 145;
const rootH = 50;
const nodeW = 80;
const nodeH = 35;
const centerVertical = y + h / 2;
const rootX = x;
const rootY = centerVertical - rootH / 2;
type MindMapNode = {
children: MindMapNode[];
text: string;
xywh: string;
};
const root: MindMapNode = {
children: [],
text: 'Mind Map',
xywh: `[${rootX},${rootY},${rootW},${rootH}]`,
};
for (let i = 0; i < 3; i++) {
const nodeX = x + rootW + 300;
const nodeY = centerVertical - nodeH / 2 + (i - 1) * 50;
root.children.push({
children: [],
text: 'Text',
xywh: `[${nodeX},${nodeY},${nodeW},${nodeH}]`,
});
}
const mindmapId = edgelessService.crud.addElement('mindmap', {
style: mindmapStyle,
children: root,
}) as string;
edgelessService.std
.getOptional(TelemetryProvider)
?.track('CanvasElementAdded', {
control: 'toolbar:dnd', // for now we use toolbar:dnd for all mindmap creation here
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'mindmap',
});
return mindmapId;
};
export const textRender: DraggableTool['render'] = async (
bound,
service,
edgeless
) => {
const vCenter = bound.y + bound.h / 2;
const w = 100;
const h = 32;
const flag = edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_edgeless_text');
let id: string;
if (flag) {
const [_, { textId }] = edgeless.std.command.exec(
insertEdgelessTextCommand,
{
x: bound.x,
y: vCenter - h / 2,
}
);
id = textId!;
} else {
id = service.crud.addElement(CanvasElementType.TEXT, {
xywh: new Bound(bound.x, vCenter - h / 2, w, h).serialize(),
text: new Y.Text(),
}) as string;
edgeless.doc.captureSync();
const textElement = edgeless.service.crud.getElementById(id);
assertInstanceOf(textElement, TextElementModel);
mountTextElementEditor(textElement, edgeless);
}
service.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'toolbar:dnd',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'text',
});
return id;
};
export const mediaRender: DraggableTool['render'] = async (
bound,
_,
edgeless
) => {
let file: File | null = null;
try {
file = await openFileOrFiles();
} catch (e) {
console.error(e);
return null;
}
if (!file) return null;
// image
if (file.type.startsWith('image/')) {
const [id] = await addImages(edgeless.std, [file], {
point: [bound.x, bound.y],
maxWidth: MAX_IMAGE_WIDTH,
transformPoint: false,
});
if (id) return id;
return null;
}
// attachment
const [id] = await addAttachments(
edgeless.std,
[file],
[bound.x, bound.y],
false
);
return id;
};
const toolStyle2StyleObj = (state: ConfigState, style: ConfigStyle = {}) => {
const styleObj = {} as Record<string, string>;
for (const [key, value] of Object.entries(style)) {
styleObj[`--${state}-${key}`] = `${value}${unitMap[key as ConfigProperty]}`;
}
return styleObj;
};
export const toolConfig2StyleObj = (config: ToolConfig) => {
const styleObj = {} as Record<string, string>;
for (const [state, style] of Object.entries(config)) {
Object.assign(
styleObj,
toolStyle2StyleObj(state as ConfigState, {
...config.default,
...style,
})
);
}
return styleObj;
};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
import { LightLoadingIcon } from '@blocksuite/affine-components/icons';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ShadowlessElement } from '@blocksuite/block-std';
import { css, html } from 'lit';
import { importMindMapIcon } from './icons.js';
export class MindMapPlaceholder extends ShadowlessElement {
static override styles = css`
mindmap-import-placeholder {
display: flex;
flex-direction: column;
padding: 28px 12px 12px;
box-sizing: border-box;
width: 200px;
height: 122px;
border-radius: 12px;
gap: 12px;
background-color: ${unsafeCSSVarV2('layer/background/secondary')};
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
color: ${unsafeCSSVarV2('text/placeholder')};
box-shadow: 0px 0px 4px 0px rgba(66, 65, 73, 0.14);
}
mindmap-import-placeholder .preview-icon {
text-align: center;
}
mindmap-import-placeholder .description {
display: flex;
gap: 8px;
color: ${unsafeCSSVarV2('text/placeholder')};
font-size: 14px;
line-height: 22px;
align-items: center;
}
`;
override render() {
return html`<div class="placeholder-container">
<div class="preview-icon">${importMindMapIcon}</div>
<div class="description">
${LightLoadingIcon}
<span>Importing mind map...</span>
</div>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'mindmap-import-placeholder': MindMapPlaceholder;
}
}
@@ -0,0 +1,403 @@
import { toast } from '@blocksuite/affine-components/toast';
import type { MindmapStyle } from '@blocksuite/affine-model';
import {
EditPropsStore,
FeatureFlagService,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { BlockStdScope } from '@blocksuite/block-std';
import { modelContext, stdContext } from '@blocksuite/block-std';
import { ErrorCode } from '@blocksuite/global/exceptions';
import type { Bound } from '@blocksuite/global/utils';
import { SignalWatcher } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
import { consume } from '@lit/context';
import { computed } from '@preact/signals-core';
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { EdgelessRootBlockComponent } from '../../../index.js';
import { getTooltipWithShortcut } from '../../utils.js';
import { EdgelessDraggableElementController } from '../common/draggable/draggable-element.controller.js';
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
import { getMindMaps, type ToolbarMindmapItem } from './assets.js';
import { mediaRender, textRender } from './basket-elements.js';
import { importMindMapIcon, mindmapMenuMediaIcon, textIcon } from './icons.js';
import { MindMapPlaceholder } from './mindmap-importing-placeholder.js';
type TextItem = {
type: 'text';
icon: TemplateResult;
render: typeof textRender;
};
type MediaItem = {
type: 'media';
icon: TemplateResult;
render: typeof mediaRender;
};
type ImportItem = {
type: 'import';
icon: TemplateResult;
};
const textItem: TextItem = { type: 'text', icon: textIcon, render: textRender };
const mediaItem: MediaItem = {
type: 'media',
icon: mindmapMenuMediaIcon,
render: mediaRender,
};
export class EdgelessMindmapMenu extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
display: flex;
z-index: -1;
justify-content: flex-end;
}
.text-and-mindmap {
display: flex;
gap: 10px;
padding: 8px 0px;
box-sizing: border-box;
}
.thin-divider {
width: 1px;
transform: scaleX(0.5);
height: 48px;
background: var(--affine-border-color);
}
.text-item,
.media-item {
width: 60px;
}
.mindmap-item {
width: 64px;
}
.text-item,
.media-item,
.mindmap-item {
border-radius: 4px;
height: 48px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.text-item > button,
.media-item > button,
.mindmap-item > button {
position: absolute;
border-radius: inherit;
border: none;
background: none;
cursor: grab;
padding: 0;
}
.text-item:hover,
.media-item:hover,
.mindmap-item[data-is-active='true'],
.mindmap-item:hover {
background: var(--affine-hover-color);
}
.text-item > button.next,
.media-item > button.next,
.mindmap-item > button.next {
transition: transform 0.3s ease-in-out;
}
`;
private readonly _style$ = computed(() => {
const { style } =
this.edgeless.std.get(EditPropsStore).lastProps$.value.mindmap;
return style;
});
draggableController!: EdgelessDraggableElementController<
ToolbarMindmapItem | TextItem | ImportItem | MediaItem
>;
override type = 'empty' as const;
private get _rootBlock(): EdgelessRootBlockComponent {
return this.std.view.getBlock(this.model.id) as EdgelessRootBlockComponent;
}
get mindMaps() {
return getMindMaps(this.theme);
}
private _importMindMapEntry() {
const { draggingElement } = this.draggableController?.states || {};
const isBeingDragged = draggingElement?.data.type === 'import';
return html`<div class="mindmap-item">
<button
style="opacity: ${isBeingDragged ? 0 : 1}"
class="next"
@mousedown=${(e: MouseEvent) => {
this.draggableController.onMouseDown(e, {
preview: importMindMapIcon,
data: {
type: 'import',
icon: importMindMapIcon,
},
standardWidth: 350,
});
}}
@touchstart=${(e: TouchEvent) => {
this.draggableController.onTouchStart(e, {
preview: importMindMapIcon,
data: {
type: 'import',
icon: importMindMapIcon,
},
standardWidth: 350,
});
}}
@click=${() => {
this.draggableController.cancel();
const viewportBound = this._rootBlock.service.viewport.viewportBounds;
viewportBound.x += viewportBound.w / 2;
viewportBound.y += viewportBound.h / 2;
this._onImportMindMap(viewportBound);
}}
>
${importMindMapIcon}
</button>
<affine-tooltip tip-position="top" .offset=${12}>
${getTooltipWithShortcut('Support import of FreeMind,OPML.')}
</affine-tooltip>
</div>`;
}
private _onImportMindMap(bound: Bound) {
const edgelessBlock = this._rootBlock;
if (!edgelessBlock) return;
const placeholder = new MindMapPlaceholder();
placeholder.style.position = 'absolute';
placeholder.style.left = `${bound.x}px`;
placeholder.style.top = `${bound.y}px`;
edgelessBlock.gfxViewportElm.append(placeholder);
this.onImportMindMap?.(bound)
.then(() => {
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
page: 'whiteboard editor',
type: 'imported mind map',
other: 'success',
module: 'toolbar',
});
})
.catch(e => {
if (e.code === ErrorCode.UserAbortError) return;
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
page: 'whiteboard editor',
type: 'imported mind map',
other: 'failed',
module: 'toolbar',
});
toast(this.edgeless.host, 'Import failed, please try again');
console.error(e);
})
.finally(() => {
placeholder.remove();
});
}
initDragController() {
if (this.draggableController || !this.edgeless) return;
this.draggableController = new EdgelessDraggableElementController(this, {
service: this.edgeless.service,
edgeless: this.edgeless,
scopeElement: this,
clickToDrag: true,
onOverlayCreated: (_layer, element) => {
if (element.data.type === 'mindmap') {
this.onActiveStyleChange?.(element.data.style);
}
// a workaround to active mindmap, so that menu cannot be closed by `Escape`
this.setEdgelessTool({ type: 'empty' });
},
onDrop: (element, bound) => {
if ('render' in element.data) {
element.data
.render(bound, this.edgeless.service, this.edgeless)
.then(id => {
if (!id) return;
if (element.data.type === 'mindmap') {
this.onActiveStyleChange?.(element.data.style);
this.setEdgelessTool({ type: 'default' });
this.edgeless.gfx.selection.set({
elements: [id],
editing: false,
});
} else if (
element.data.type === 'text' ||
element.data.type === 'media'
) {
this.setEdgelessTool({ type: 'default' });
}
})
.catch(console.error);
} else if (element.data.type === 'import') {
this._onImportMindMap?.(bound);
}
},
});
}
override render() {
const { cancelled, draggingElement, dragOut } =
this.draggableController?.states || {};
const isDraggingMedia = draggingElement?.data?.type === 'media';
const isDraggingText = draggingElement?.data?.type === 'text';
const showNextText = dragOut && !cancelled;
return html`<edgeless-slide-menu .height=${'64px'}>
<div class="text-and-mindmap">
<div class="media-item">
${isDraggingMedia
? html`<button
class="next"
style="transform: translateY(${showNextText ? 0 : 64}px)"
>
${mediaItem.icon}
</button>`
: nothing}
<button
style="opacity: ${isDraggingMedia ? 0 : 1}"
@mousedown=${(e: MouseEvent) =>
this.draggableController.onMouseDown(e, {
preview: mediaItem.icon,
data: mediaItem,
})}
@touchstart=${(e: TouchEvent) =>
this.draggableController.onTouchStart(e, {
preview: mediaItem.icon,
data: mediaItem,
})}
>
${mediaItem.icon}
</button>
<affine-tooltip tip-position="top" .offset=${12}>
${getTooltipWithShortcut('Add media')}
</affine-tooltip>
</div>
<div class="thin-divider"></div>
<div class="text-item">
${isDraggingText
? html`<button
class="next"
style="transform: translateY(${showNextText ? 0 : 64}px)"
>
${textItem.icon}
</button>`
: nothing}
<button
style="opacity: ${isDraggingText ? 0 : 1}"
@mousedown=${(e: MouseEvent) =>
this.draggableController.onMouseDown(e, {
preview: textItem.icon,
data: textItem,
})}
@touchstart=${(e: TouchEvent) =>
this.draggableController.onTouchStart(e, {
preview: textItem.icon,
data: textItem,
})}
>
${textItem.icon}
</button>
<affine-tooltip tip-position="top" .offset=${12}>
${getTooltipWithShortcut('Edgeless Text', 'T')}
</affine-tooltip>
</div>
<div class="thin-divider"></div>
<!-- mind map -->
${repeat(this.mindMaps, mindMap => {
const isDraggingMindMap = draggingElement?.data?.type !== 'text';
const draggingEle = draggingElement?.data as ToolbarMindmapItem;
const isBeingDragged =
isDraggingMindMap && draggingEle?.style === mindMap.style;
const showNext = dragOut && !cancelled;
const isActive = this._style$.value === mindMap.style;
return html`
<div class="mindmap-item" data-is-active=${isActive}>
${isBeingDragged
? html`<button
style="transform: translateY(${showNext ? 0 : 64}px)"
class="next"
>
${mindMap.icon}
</button>`
: nothing}
<button
style="opacity: ${isBeingDragged ? 0 : 1}"
@mousedown=${(e: MouseEvent) => {
this.draggableController.onMouseDown(e, {
preview: mindMap.icon,
data: mindMap,
standardWidth: 350,
});
}}
@touchstart=${(e: TouchEvent) => {
this.draggableController.onTouchStart(e, {
preview: mindMap.icon,
data: mindMap,
standardWidth: 350,
});
}}
@click=${() => this.onActiveStyleChange?.(mindMap.style)}
>
${mindMap.icon}
</button>
<affine-tooltip tip-position="top" .offset=${12}>
${getTooltipWithShortcut('Mind Map', 'M')}
</affine-tooltip>
</div>
`;
})}
${this.std.store
.get(FeatureFlagService)
.getFlag('enable_mind_map_import')
? this._importMindMapEntry()
: nothing}
</div>
</edgeless-slide-menu>`;
}
override updated(changedProperties: Map<PropertyKey, unknown>) {
if (!changedProperties.has('edgeless')) return;
this.initDragController();
}
@consume({ context: modelContext })
accessor model!: BlockModel;
@property({ attribute: false })
accessor onActiveStyleChange!: (style: MindmapStyle) => void;
@property({ attribute: false })
accessor onImportMindMap!: (bound: Bound) => Promise<void>;
@consume({ context: stdContext })
accessor std!: BlockStdScope;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-mindmap-menu': EdgelessMindmapMenu;
}
}
@@ -0,0 +1,454 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type {
MindmapElementModel,
MindmapStyle,
} from '@blocksuite/affine-model';
import {
EditPropsStore,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import type { Bound } from '@blocksuite/global/utils';
import { SignalWatcher } from '@blocksuite/global/utils';
import { computed } from '@preact/signals-core';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { EdgelessDraggableElementController } from '../common/draggable/draggable-element.controller.js';
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
import { getMindMaps } from './assets.js';
import {
type DraggableTool,
getMindmapRender,
mediaConfig,
mediaRender,
mindmapConfig,
textConfig,
textRender,
toolConfig2StyleObj,
} from './basket-elements.js';
import {
basketIconDark,
basketIconLight,
mindmapMenuMediaIcon,
textIcon,
} from './icons.js';
import { importMindmap } from './utils/import-mindmap.js';
export class EdgelessMindmapToolButton extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.partial-clip {
flex-shrink: 0;
box-sizing: border-box;
width: calc(100% + 20px);
pointer-events: none;
padding: 0 10px;
overflow: hidden;
}
.basket-wrapper {
pointer-events: auto;
height: 64px;
width: 96px;
display: flex;
justify-content: center;
align-items: flex-end;
position: relative;
}
.basket,
.basket-tool-item {
transition: transform 0.3s ease-in-out;
position: absolute;
}
.basket {
bottom: 0;
height: 17px;
width: 76px;
}
.basket > div,
.basket > svg {
position: absolute;
}
.glass {
width: 76px;
height: 17px;
border-radius: 2px;
mask: url(#mindmap-basket-body-mask);
}
.glass.enabled {
backdrop-filter: blur(2px);
}
@-moz-document url-prefix() {
.glass.enabled {
backdrop-filter: none;
}
}
.basket {
z-index: 3;
}
.basket-tool-item {
cursor: grab;
}
.basket-tool-item svg {
display: block;
}
.basket-tool-item {
transform: translate(var(--default-x, 0), var(--default-y, 0))
rotate(var(--default-r, 0)) scale(var(--default-s, 1));
z-index: var(--default-z, 0);
}
.basket-tool-item.next {
transform: translate(var(--next-x, 0), var(--next-y, 0))
rotate(var(--next-r, 0)) scale(var(--next-s, 1));
z-index: var(--next-z, 0);
}
/* active & hover */
.basket-wrapper:hover .basket,
.basket-wrapper.active .basket {
z-index: 0;
}
.basket-wrapper:hover .basket-tool-item.current,
.basket-wrapper.active .basket-tool-item.current {
transform: translate(var(--active-x, 0), var(--active-y, 0))
rotate(var(--active-r, 0)) scale(var(--active-s, 1));
z-index: var(--active-z, 0);
}
.basket-tool-item.next.coming,
.basket-wrapper:hover .basket-tool-item.current:hover {
transform: translate(var(--hover-x, 0), var(--hover-y, 0))
rotate(var(--hover-r, 0)) scale(var(--hover-s, 1));
z-index: var(--hover-z, 0);
}
`;
private readonly _style$ = computed(() => {
const { style } =
this.edgeless.std.get(EditPropsStore).lastProps$.value.mindmap;
return style;
});
draggableController!: EdgelessDraggableElementController<DraggableTool>;
override enableActiveBackground = true;
override type: GfxToolsFullOptionValue['type'][] = ['empty', 'text'];
get draggableTools(): DraggableTool[] {
const style = this._style$.value;
const mindmap =
this.mindmaps.find(m => m.style === style) || this.mindmaps[0];
return [
{
name: 'media',
icon: mindmapMenuMediaIcon,
config: mediaConfig,
standardWidth: 100,
render: mediaRender,
},
{
name: 'text',
icon: textIcon,
config: textConfig,
standardWidth: 100,
render: textRender,
},
{
name: 'mindmap',
icon: mindmap.icon,
config: mindmapConfig,
standardWidth: 350,
render: getMindmapRender(style),
},
];
}
get mindmaps() {
return getMindMaps(this.theme);
}
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private _toggleMenu() {
if (this.tryDisposePopper()) return;
this.setEdgelessTool({ type: 'default' });
const menu = this.createPopper('edgeless-mindmap-menu', this);
Object.assign(menu.element, {
edgeless: this.edgeless,
onActiveStyleChange: (style: MindmapStyle) => {
this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', {
style,
});
},
onImportMindMap: (bound: Bound) => {
return importMindmap(bound).then(mindmap => {
const id = this.crud.addElement('mindmap', {
children: mindmap,
layoutType: mindmap?.layoutType === 'left' ? 1 : 0,
});
if (!id) return;
const element = this.crud.getElementById(id) as MindmapElementModel;
this.tryDisposePopper();
this.setEdgelessTool({ type: 'default' });
this.edgeless.gfx.selection.set({
elements: [element.tree.id],
editing: false,
});
});
},
});
}
initDragController() {
if (!this.edgeless || !this.toolbarContainer) return;
if (this.draggableController) return;
this.draggableController = new EdgelessDraggableElementController(this, {
service: this.edgeless.service,
edgeless: this.edgeless,
scopeElement: this.toolbarContainer,
standardWidth: 100,
clickToDrag: true,
onOverlayCreated: (overlay, { data }) => {
const tool = this.draggableTools.find(t => t.name === data.name);
if (!tool) return;
// recover the rotation
const rotate = tool.config?.hover?.r ?? tool.config?.default?.r ?? 0;
overlay.element.style.setProperty('--rotate', rotate + 'deg');
setTimeout(() => {
overlay.transitionWrapper.style.setProperty(
'--rotate',
-rotate + 'deg'
);
}, 50);
// set the scale (without transition)
const scale = tool.config?.hover?.s ?? tool.config?.default?.s ?? 1;
overlay.element.style.setProperty('--scale', `${scale}`);
// a workaround to handle getBoundingClientRect() when the element is rotated
const _left = parseInt(overlay.element.style.left);
const _top = parseInt(overlay.element.style.top);
if (data.name === 'mindmap') {
overlay.element.style.left = _left + 3 + 'px';
overlay.element.style.top = _top + 5 + 'px';
} else if (data.name === 'text') {
overlay.element.style.left = _left + 0 + 'px';
overlay.element.style.top = _top + 3 + 'px';
}
this.readyToDrop = true;
},
onCanceled: overlay => {
overlay.transitionWrapper.style.transformOrigin = 'unset';
overlay.transitionWrapper.style.setProperty('--rotate', '0deg');
this.readyToDrop = false;
},
onDrop: (el, bound) => {
el.data
.render(bound, this.edgeless.service, this.edgeless)
.then(id => {
if (!id) return;
this.readyToDrop = false;
if (el.data.name === 'mindmap') {
this.setEdgelessTool({ type: 'default' });
this.edgeless.gfx.selection.set({
elements: [id],
editing: false,
});
} else if (el.data.name === 'text') {
this.setEdgelessTool({ type: 'default' });
}
})
.catch(console.error);
},
});
this.edgeless.bindHotKey(
{
m: () => {
const service = this.edgeless.service;
if (service.locked) return;
if (service.selection.editing) return;
if (this.readyToDrop) {
// change the style
const activeIndex = this.mindmaps.findIndex(
m => m.style === this._style$.value
);
const nextIndex = (activeIndex + 1) % this.mindmaps.length;
const next = this.mindmaps[nextIndex];
this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', {
style: next.style,
});
const tool = this.draggableTools.find(t => t.name === 'mindmap');
this.draggableController.updateElementInfo({
data: tool,
preview: next.icon,
});
return;
}
this.setEdgelessTool({ type: 'empty' });
const icon = this.mindmapElement;
const { x, y } = service.gfx.tool.lastMousePos$.peek();
const { left, top } = this.edgeless.viewport;
const clientPos = { x: x + left, y: y + top };
this.draggableController.clickToDrag(icon, clientPos);
},
},
{ global: true }
);
}
override render() {
const { popper } = this;
const appTheme = this.edgeless.std.get(ThemeProvider).app$.value;
const basketIcon = appTheme === 'light' ? basketIconLight : basketIconDark;
const glassBg =
appTheme === 'light' ? 'rgba(255,255,255,0.5)' : 'rgba(74, 74, 74, 0.6)';
const { cancelled, dragOut, draggingElement } =
this.draggableController?.states || {};
const active = popper || draggingElement;
return html`<edgeless-toolbar-button
class="edgeless-mindmap-button"
?withHover=${true}
.tooltip=${popper ? '' : 'Others'}
.tooltipOffset=${4}
@click=${this._toggleMenu}
style="width: 100%; height: 100%; display: inline-block"
>
<div class="partial-clip">
<div class="basket-wrapper ${active ? 'active' : ''}">
${repeat(
this.draggableTools,
t => t.name,
tool => {
const isBeingDragged = draggingElement?.data.name === tool.name;
const variables = toolConfig2StyleObj(tool.config);
const nextStyle = styleMap({
...variables,
});
const currentStyle = styleMap({
...variables,
opacity: isBeingDragged ? 0 : 1,
pointerEvents: draggingElement ? 'none' : 'auto',
});
return html`${isBeingDragged
? html`<div
class=${classMap({
'basket-tool-item': true,
next: true,
coming: !!dragOut && !cancelled,
})}
style=${nextStyle}
>
${tool.icon}
</div>`
: nothing}
<div
style=${currentStyle}
@mousedown=${(e: MouseEvent) =>
this.draggableController.onMouseDown(e, {
data: tool,
preview: tool.icon,
standardWidth: tool.standardWidth,
})}
@touchstart=${(e: TouchEvent) =>
this.draggableController.onTouchStart(e, {
data: tool,
preview: tool.icon,
standardWidth: tool.standardWidth,
})}
class="basket-tool-item current ${tool.name}"
>
${tool.icon}
</div>`;
}
)}
<div class="basket">
<div
class="glass ${this.enableBlur ? 'enabled' : ''}"
style="background: ${glassBg}"
></div>
${basketIcon}
</div>
</div>
</div>
<svg width="0" height="0" style="opacity: 0; pointer-events: none">
<defs>
<mask id="mindmap-basket-body-mask">
<rect
x="2"
width="71.8"
y="2"
height="15"
rx="1.5"
ry="1.5"
fill="white"
/>
<rect
width="32"
height="6"
x="22"
y="5.9"
fill="black"
rx="3"
ry="3"
/>
</mask>
</defs>
</svg>
</edgeless-toolbar-button>`;
}
override updated(_changedProperties: Map<PropertyKey, unknown>) {
const controllerRequiredProps = ['edgeless', 'toolbarContainer'] as const;
if (
controllerRequiredProps.some(p => _changedProperties.has(p)) &&
!this.draggableController
) {
this.initDragController();
}
}
@property({ type: Boolean })
accessor enableBlur = true;
@query('.basket-tool-item.mindmap')
accessor mindmapElement!: HTMLElement;
@state()
accessor readyToDrop = false;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-mindmap-tool-button': EdgelessMindmapToolButton;
}
}
@@ -0,0 +1,145 @@
import { openFileOrFiles } from '@blocksuite/affine-shared/utils';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { Bound } from '@blocksuite/global/utils';
import c from 'simple-xml-to-json';
type MindMapNode = {
children: MindMapNode[];
text: string;
xywh?: string;
title?: string;
layoutType?: 'left' | 'right';
};
export async function importMindmap(bound: Bound): Promise<MindMapNode> {
const file = await openFileOrFiles({
acceptType: 'MindMap',
});
if (!file) {
throw new BlockSuiteError(ErrorCode.UserAbortError, 'Aborted by user');
}
let result;
if (file.name.endsWith('.mm')) {
result = await parseMmFile(file);
} else if (file.name.endsWith('.opml') || file.name.endsWith('.xml')) {
result = await parseOPMLFile(file);
} else {
throw new BlockSuiteError(ErrorCode.ParsingError, 'Unsupported file type');
}
if (result) {
result.xywh = bound.serialize();
}
return result;
}
function readAsText(file: File) {
return file.text();
}
type RawMmNode = {
node?: {
TEXT: string;
POSITION: 'left' | 'right';
children?: RawMmNode[];
};
};
async function parseMmFile(file: File): Promise<MindMapNode> {
const content = await readAsText(file);
try {
const parsed = c.convertXML(content);
const map = parsed.map.children[0];
const traverse = (node: RawMmNode): MindMapNode | null => {
if (!node.node) {
return null;
}
return node.node.POSITION
? {
layoutType: node.node.POSITION,
text: node.node.TEXT ?? 'MINDMAP',
children:
(node.node.children
?.map(traverse)
.filter(node => node) as MindMapNode[]) ?? [],
}
: {
text: node.node.TEXT ?? 'MINDMAP',
children:
(node.node.children
?.map(traverse)
.filter(node => node) as MindMapNode[]) ?? [],
};
};
const result = traverse(map);
if (!result) {
throw new BlockSuiteError(
ErrorCode.ParsingError,
'Failed to parse mm file'
);
}
return result;
} catch (e) {
console.error(e);
throw new BlockSuiteError(
ErrorCode.ParsingError,
'Failed to parse mm file'
);
}
}
type RawOPMLOutline = {
outline: {
text: string;
children: RawOPMLOutline[];
};
};
async function parseOPMLFile(file: File): Promise<MindMapNode> {
const content = await readAsText(file);
try {
const parsed = c.convertXML(content);
const outline = parsed.opml?.children[1].body?.children?.[0];
const traverse = (node: RawOPMLOutline): MindMapNode | null => {
if (!node.outline?.text && !node.outline?.children) {
return null;
}
return {
text: node.outline?.text ?? 'MINDMAP',
children: node.outline.children
? (node.outline.children.map(traverse) as MindMapNode[])
: [],
};
};
const result = traverse(outline);
if (!result) {
throw new BlockSuiteError(
ErrorCode.ParsingError,
'Failed to parse OPML file'
);
}
return result;
} catch (e) {
console.error(e);
throw new BlockSuiteError(
ErrorCode.ParsingError,
'Failed to parse OPML 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>;
};
@@ -0,0 +1,174 @@
import type { ColorScheme } from '@blocksuite/affine-model';
import type {
GfxToolsFullOption,
GfxToolsFullOptionValue,
ToolController,
} from '@blocksuite/block-std/gfx';
import {
type Constructor,
// oxlint-disable-next-line no-unused-vars
type DisposableClass,
WithDisposable,
} from '@blocksuite/global/utils';
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 type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
import { createPopper, type MenuPopper } from '../common/create-popper.js';
import {
edgelessToolbarContext,
type EdgelessToolbarSlots,
edgelessToolbarSlotsContext,
edgelessToolbarThemeContext,
} from '../context.js';
import type { EdgelessToolbarWidget } from '../edgeless-toolbar.js';
type ValueOf<T> = T[keyof T];
export declare abstract class EdgelessToolbarToolClass extends DisposableClass {
active: boolean;
createPopper: typeof createPopper;
edgeless: EdgelessRootBlockComponent;
edgelessTool: GfxToolsFullOptionValue;
enableActiveBackground?: boolean;
popper: MenuPopper<HTMLElement> | null;
setEdgelessTool: ToolController['setTool'];
theme: ColorScheme;
toolbarContainer: HTMLElement | null;
toolbarSlots: EdgelessToolbarSlots;
/**
* @return true if operation was successful
*/
tryDisposePopper: () => boolean;
abstract type:
| GfxToolsFullOptionValue['type']
| GfxToolsFullOptionValue['type'][];
accessor toolbar: EdgelessToolbarWidget;
}
export const EdgelessToolbarToolMixin = <T extends Constructor<LitElement>>(
SuperClass: T
) => {
abstract class DerivedClass extends WithDisposable(SuperClass) {
enableActiveBackground = false;
abstract type:
| GfxToolsFullOptionValue['type']
| GfxToolsFullOptionValue['type'][];
get active() {
const { type } = this;
const activeType = this.edgelessTool?.type;
return activeType
? Array.isArray(type)
? type.includes(activeType)
: activeType === type
: false;
}
get setEdgelessTool() {
return (...args: Parameters<ToolController['setTool']>) => {
this.edgeless.gfx.tool.setTool(
// @ts-expect-error FIXME: ts error
...args
);
};
}
private _applyActiveStyle() {
if (!this.enableActiveBackground) return;
this.style.background = this.active
? cssVar('hoverColor')
: 'transparent';
}
private _updateActiveEdgelessTool() {
this.edgelessTool = this.edgeless.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!: EdgelessRootBlockComponent;
@state()
accessor edgelessTool!: ValueOf<GfxToolsFullOption> | 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>;
};
@@ -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>;
};
@@ -0,0 +1,23 @@
import { svg } from 'lit';
export const toShapeNotToAdapt = svg`<svg width="44" height="5" viewBox="0 0 44 5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M43.9013 1.45752V1.94112H42.5034V1.45752H43.9013ZM42.8208 0.901367H43.4646V3.06551C43.4646 3.12496 43.4737 3.1713 43.4918 3.20455C43.5099 3.23679 43.5351 3.25946 43.5674 3.27256C43.6006 3.28565 43.6389 3.2922 43.6822 3.2922C43.7124 3.2922 43.7427 3.28968 43.7729 3.28465C43.8031 3.2786 43.8263 3.27407 43.8424 3.27105L43.9437 3.75012C43.9114 3.76019 43.8661 3.77178 43.8076 3.78488C43.7492 3.79898 43.6782 3.80755 43.5946 3.81057C43.4394 3.81662 43.3034 3.79596 43.1865 3.74861C43.0706 3.70126 42.9805 3.62771 42.916 3.52796C42.8515 3.42822 42.8198 3.30228 42.8208 3.15014V0.901367Z" fill="currentColor"/>
<path d="M39.9741 4.64977V1.45796H40.6089V1.84787H40.6376C40.6658 1.7854 40.7066 1.72193 40.76 1.65745C40.8144 1.59196 40.8849 1.53755 40.9716 1.49423C41.0592 1.4499 41.168 1.42773 41.298 1.42773C41.4673 1.42773 41.6234 1.47207 41.7665 1.56073C41.9096 1.64838 42.0239 1.78087 42.1096 1.95819C42.1952 2.13451 42.238 2.35566 42.238 2.62164C42.238 2.88057 42.1962 3.0992 42.1126 3.27753C42.03 3.45486 41.9171 3.58936 41.774 3.68104C41.632 3.77172 41.4728 3.81706 41.2965 3.81706C41.1716 3.81706 41.0653 3.79641 40.9776 3.7551C40.891 3.71379 40.8199 3.6619 40.7645 3.59944C40.7091 3.53596 40.6668 3.47199 40.6376 3.4075H40.6179V4.64977H39.9741ZM40.6043 2.61862C40.6043 2.75665 40.6235 2.87705 40.6618 2.97981C40.7 3.08258 40.7555 3.16268 40.828 3.22011C40.9005 3.27653 40.9887 3.30474 41.0925 3.30474C41.1972 3.30474 41.2859 3.27602 41.3584 3.21859C41.431 3.16016 41.4859 3.07956 41.5232 2.97679C41.5615 2.87302 41.5806 2.75363 41.5806 2.61862C41.5806 2.48462 41.562 2.36674 41.5247 2.26498C41.4874 2.16322 41.4325 2.08363 41.36 2.0262C41.2874 1.96877 41.1983 1.94006 41.0925 1.94006C40.9877 1.94006 40.899 1.96776 40.8265 2.02318C40.7549 2.07859 40.7 2.15718 40.6618 2.25894C40.6235 2.36069 40.6043 2.48059 40.6043 2.61862Z" fill="currentColor"/>
<path d="M38.1667 3.8231C38.0186 3.8231 37.8867 3.79741 37.7708 3.74603C37.6549 3.69364 37.5632 3.61656 37.4957 3.5148C37.4292 3.41204 37.396 3.28408 37.396 3.13094C37.396 3.00198 37.4197 2.89367 37.467 2.80602C37.5144 2.71836 37.5789 2.64784 37.6605 2.59444C37.7421 2.54104 37.8348 2.50074 37.9385 2.47354C38.0433 2.44633 38.1531 2.42719 38.268 2.41611C38.403 2.402 38.5118 2.38891 38.5944 2.37681C38.6771 2.36372 38.737 2.34457 38.7743 2.31939C38.8116 2.2942 38.8302 2.25692 38.8302 2.20755V2.19848C38.8302 2.10277 38.8 2.02872 38.7395 1.97633C38.6801 1.92394 38.5954 1.89774 38.4856 1.89774C38.3698 1.89774 38.2776 1.92343 38.2091 1.97482C38.1406 2.02519 38.0952 2.08867 38.073 2.16524L37.4776 2.11688C37.5078 1.97582 37.5673 1.85391 37.6559 1.75115C37.7446 1.64737 37.8589 1.56778 37.999 1.51237C38.14 1.45594 38.3033 1.42773 38.4886 1.42773C38.6176 1.42773 38.741 1.44285 38.8589 1.47307C38.9778 1.5033 39.0831 1.55015 39.1748 1.61362C39.2675 1.67709 39.3405 1.7587 39.3939 1.85845C39.4473 1.95718 39.474 2.07557 39.474 2.2136V3.77928H38.8634V3.45738H38.8453C38.808 3.52992 38.7582 3.59389 38.6957 3.64931C38.6332 3.70371 38.5582 3.74653 38.4705 3.77777C38.3829 3.80799 38.2816 3.8231 38.1667 3.8231ZM38.3511 3.37879C38.4458 3.37879 38.5295 3.36015 38.602 3.32287C38.6745 3.28459 38.7315 3.2332 38.7728 3.16872C38.8141 3.10424 38.8347 3.0312 38.8347 2.94959V2.70325C38.8146 2.71635 38.7869 2.72844 38.7516 2.73952C38.7174 2.7496 38.6786 2.75917 38.6352 2.76823C38.5919 2.7763 38.5486 2.78385 38.5053 2.7909C38.4619 2.79695 38.4227 2.80249 38.3874 2.80753C38.3118 2.81861 38.2458 2.83624 38.1894 2.86042C38.133 2.8846 38.0892 2.91735 38.0579 2.95866C38.0267 2.99896 38.0111 3.04933 38.0111 3.10978C38.0111 3.19744 38.0428 3.26444 38.1063 3.31078C38.1708 3.35612 38.2524 3.37879 38.3511 3.37879Z" fill="currentColor"/>
<path d="M35.6544 3.81647C35.4781 3.81647 35.3184 3.77113 35.1753 3.68045C35.0333 3.58877 34.9204 3.45426 34.8368 3.27694C34.7542 3.09861 34.7129 2.87998 34.7129 2.62105C34.7129 2.35506 34.7557 2.13391 34.8413 1.9576C34.927 1.78028 35.0408 1.64779 35.1829 1.56013C35.326 1.47147 35.4826 1.42714 35.6529 1.42714C35.7829 1.42714 35.8912 1.44931 35.9778 1.49364C36.0655 1.53696 36.136 1.59137 36.1894 1.65685C36.2438 1.72134 36.2851 1.78481 36.3133 1.84728H36.333V0.683594H36.9753V3.77868H36.3405V3.40691H36.3133C36.2831 3.47139 36.2403 3.53537 36.1849 3.59884C36.1305 3.66131 36.0594 3.7132 35.9718 3.7545C35.8851 3.79581 35.7793 3.81647 35.6544 3.81647ZM35.8584 3.30414C35.9622 3.30414 36.0499 3.27593 36.1214 3.21951C36.1939 3.16208 36.2494 3.08199 36.2876 2.97922C36.3269 2.87645 36.3466 2.75605 36.3466 2.61803C36.3466 2.48 36.3274 2.3601 36.2891 2.25834C36.2509 2.15658 36.1955 2.078 36.1229 2.02258C36.0504 1.96717 35.9622 1.93946 35.8584 1.93946C35.7526 1.93946 35.6635 1.96818 35.5909 2.02561C35.5184 2.08303 35.4635 2.16263 35.4262 2.26439C35.3889 2.36615 35.3703 2.48403 35.3703 2.61803C35.3703 2.75303 35.3889 2.87242 35.4262 2.9762C35.4645 3.07896 35.5194 3.15957 35.5909 3.218C35.6635 3.27543 35.7526 3.30414 35.8584 3.30414Z" fill="currentColor"/>
<path d="M32.9929 3.82213C32.8448 3.82213 32.7128 3.79644 32.597 3.74505C32.4811 3.69266 32.3894 3.61559 32.3219 3.51383C32.2554 3.41106 32.2222 3.28311 32.2222 3.12996C32.2222 3.001 32.2458 2.89269 32.2932 2.80504C32.3406 2.71739 32.405 2.64686 32.4866 2.59346C32.5682 2.54006 32.6609 2.49976 32.7647 2.47256C32.8695 2.44536 32.9793 2.42621 33.0942 2.41513C33.2292 2.40103 33.338 2.38793 33.4206 2.37584C33.5032 2.36274 33.5632 2.3436 33.6005 2.31841C33.6377 2.29322 33.6564 2.25594 33.6564 2.20658V2.19751C33.6564 2.10179 33.6261 2.02774 33.5657 1.97535C33.5062 1.92296 33.4216 1.89676 33.3118 1.89676C33.1959 1.89676 33.1037 1.92246 33.0352 1.97384C32.9667 2.02422 32.9214 2.08769 32.8992 2.16426L32.3038 2.1159C32.334 1.97485 32.3934 1.85294 32.4821 1.75017C32.5708 1.6464 32.6851 1.5668 32.8252 1.51139C32.9662 1.45497 33.1294 1.42676 33.3148 1.42676C33.4438 1.42676 33.5672 1.44187 33.6851 1.4721C33.804 1.50232 33.9093 1.54917 34.0009 1.61264C34.0936 1.67612 34.1667 1.75773 34.2201 1.85747C34.2735 1.95621 34.3002 2.07459 34.3002 2.21262V3.7783H33.6896V3.4564H33.6715C33.6342 3.52894 33.5843 3.59292 33.5219 3.64833C33.4594 3.70274 33.3843 3.74556 33.2967 3.77679C33.209 3.80702 33.1078 3.82213 32.9929 3.82213ZM33.1773 3.37781C33.272 3.37781 33.3556 3.35917 33.4282 3.3219C33.5007 3.28361 33.5576 3.23223 33.5989 3.16775C33.6402 3.10327 33.6609 3.03022 33.6609 2.94861V2.70227C33.6408 2.71537 33.613 2.72746 33.5778 2.73854C33.5435 2.74862 33.5047 2.75819 33.4614 2.76726C33.4181 2.77532 33.3748 2.78287 33.3314 2.78993C33.2881 2.79597 33.2488 2.80151 33.2136 2.80655C33.138 2.81763 33.072 2.83527 33.0156 2.85945C32.9592 2.88363 32.9153 2.91637 32.8841 2.95768C32.8529 2.99798 32.8373 3.04836 32.8373 3.10881C32.8373 3.19646 32.869 3.26346 32.9325 3.30981C32.9969 3.35514 33.0786 3.37781 33.1773 3.37781Z" fill="currentColor"/>
<path d="M29.7856 3.82364C29.5508 3.82364 29.3478 3.77377 29.1765 3.67402C29.0063 3.57327 28.8748 3.43323 28.7821 3.25389C28.6894 3.07354 28.6431 2.86448 28.6431 2.62671C28.6431 2.38692 28.6894 2.17736 28.7821 1.99802C28.8748 1.81767 29.0063 1.67763 29.1765 1.57789C29.3478 1.47713 29.5508 1.42676 29.7856 1.42676C30.0203 1.42676 30.2229 1.47713 30.3931 1.57789C30.5644 1.67763 30.6964 1.81767 30.7891 1.99802C30.8818 2.17736 30.9281 2.38692 30.9281 2.62671C30.9281 2.86448 30.8818 3.07354 30.7891 3.25389C30.6964 3.43323 30.5644 3.57327 30.3931 3.67402C30.2229 3.77377 30.0203 3.82364 29.7856 3.82364ZM29.7886 3.32492C29.8954 3.32492 29.9846 3.29469 30.0561 3.23424C30.1276 3.17278 30.1815 3.08916 30.2178 2.98337C30.2551 2.87758 30.2737 2.75718 30.2737 2.62218C30.2737 2.48717 30.2551 2.36677 30.2178 2.26098C30.1815 2.15519 30.1276 2.07157 30.0561 2.01011C29.9846 1.94865 29.8954 1.91792 29.7886 1.91792C29.6808 1.91792 29.5901 1.94865 29.5166 2.01011C29.444 2.07157 29.3891 2.15519 29.3519 2.26098C29.3156 2.36677 29.2974 2.48717 29.2974 2.62218C29.2974 2.75718 29.3156 2.87758 29.3519 2.98337C29.3891 3.08916 29.444 3.17278 29.5166 3.23424C29.5901 3.29469 29.6808 3.32492 29.7886 3.32492Z" fill="currentColor"/>
<path d="M28.3413 1.45752V1.94112H26.9434V1.45752H28.3413ZM27.2607 0.901367H27.9045V3.06551C27.9045 3.12496 27.9136 3.1713 27.9317 3.20455C27.9499 3.23679 27.9751 3.25946 28.0073 3.27256C28.0405 3.28565 28.0788 3.2922 28.1222 3.2922C28.1524 3.2922 28.1826 3.28968 28.2128 3.28465C28.2431 3.2786 28.2662 3.27407 28.2823 3.27105L28.3836 3.75012C28.3514 3.76019 28.306 3.77178 28.2476 3.78488C28.1892 3.79898 28.1181 3.80755 28.0345 3.81057C27.8793 3.81662 27.7433 3.79596 27.6265 3.74861C27.5106 3.70126 27.4204 3.62771 27.3559 3.52796C27.2915 3.42822 27.2597 3.30228 27.2607 3.15014V0.901367Z" fill="currentColor"/>
<path d="M25.7007 1.45752V1.94112H24.3027V1.45752H25.7007ZM24.6201 0.901367H25.2639V3.06551C25.2639 3.12496 25.273 3.1713 25.2911 3.20455C25.3092 3.23679 25.3344 3.25946 25.3667 3.27256C25.3999 3.28565 25.4382 3.2922 25.4815 3.2922C25.5118 3.2922 25.542 3.28968 25.5722 3.28465C25.6024 3.2786 25.6256 3.27407 25.6417 3.27105L25.743 3.75012C25.7107 3.76019 25.6654 3.77178 25.607 3.78488C25.5485 3.79898 25.4775 3.80755 25.3939 3.81057C25.2387 3.81662 25.1027 3.79596 24.9858 3.74861C24.87 3.70126 24.7798 3.62771 24.7153 3.52796C24.6508 3.42822 24.6191 3.30228 24.6201 3.15014V0.901367Z" fill="currentColor"/>
<path d="M22.9062 3.82462C22.6714 3.82462 22.4684 3.77474 22.2972 3.675C22.1269 3.57425 21.9954 3.4342 21.9027 3.25487C21.81 3.07452 21.7637 2.86546 21.7637 2.62769C21.7637 2.3879 21.81 2.17833 21.9027 1.999C21.9954 1.81865 22.1269 1.67861 22.2972 1.57886C22.4684 1.47811 22.6714 1.42773 22.9062 1.42773C23.1409 1.42773 23.3435 1.47811 23.5137 1.57886C23.685 1.67861 23.817 1.81865 23.9097 1.999C24.0024 2.17833 24.0487 2.3879 24.0487 2.62769C24.0487 2.86546 24.0024 3.07452 23.9097 3.25487C23.817 3.4342 23.685 3.57425 23.5137 3.675C23.3435 3.77474 23.1409 3.82462 22.9062 3.82462ZM22.9092 3.3259C23.016 3.3259 23.1052 3.29567 23.1767 3.23522C23.2482 3.17376 23.3021 3.09014 23.3384 2.98435C23.3757 2.87856 23.3943 2.75816 23.3943 2.62315C23.3943 2.48815 23.3757 2.36775 23.3384 2.26196C23.3021 2.15617 23.2482 2.07254 23.1767 2.01109C23.1052 1.94963 23.016 1.9189 22.9092 1.9189C22.8014 1.9189 22.7107 1.94963 22.6372 2.01109C22.5646 2.07254 22.5097 2.15617 22.4725 2.26196C22.4362 2.36775 22.4181 2.48815 22.4181 2.62315C22.4181 2.75816 22.4362 2.87856 22.4725 2.98435C22.5097 3.09014 22.5646 3.17376 22.6372 3.23522C22.7107 3.29567 22.8014 3.3259 22.9092 3.3259Z" fill="currentColor"/>
<path d="M19.8538 2.43727V3.77928H19.21V1.45796H19.8235V1.86752H19.8507C19.9021 1.73251 19.9883 1.62571 20.1092 1.54712C20.2301 1.46753 20.3767 1.42773 20.549 1.42773C20.7102 1.42773 20.8507 1.463 20.9706 1.53352C21.0905 1.60405 21.1837 1.7048 21.2502 1.83578C21.3167 1.96575 21.3499 2.12091 21.3499 2.30125V3.77928H20.7061V2.41611C20.7071 2.27405 20.6709 2.16322 20.5973 2.08363C20.5238 2.00303 20.4225 1.96273 20.2935 1.96273C20.2069 1.96273 20.1303 1.98136 20.0638 2.01864C19.9983 2.05592 19.947 2.11033 19.9097 2.18186C19.8734 2.25239 19.8548 2.33752 19.8538 2.43727Z" fill="currentColor"/>
<path d="M16.7385 3.82364C16.4997 3.82364 16.2942 3.77528 16.1219 3.67856C15.9506 3.58083 15.8186 3.4428 15.726 3.26447C15.6333 3.08513 15.5869 2.87305 15.5869 2.62822C15.5869 2.38944 15.6333 2.17988 15.726 1.99953C15.8186 1.81919 15.9491 1.67864 16.1174 1.57789C16.2866 1.47713 16.4851 1.42676 16.7128 1.42676C16.866 1.42676 17.0085 1.45144 17.1405 1.50081C17.2735 1.54917 17.3894 1.62222 17.4881 1.71995C17.5878 1.81767 17.6654 1.94059 17.7208 2.0887C17.7762 2.23579 17.804 2.40808 17.804 2.60555V2.78237H15.8438V2.38339H17.1979C17.1979 2.2907 17.1778 2.20859 17.1375 2.13706C17.0972 2.06552 17.0413 2.00961 16.9697 1.96931C16.8992 1.928 16.8171 1.90734 16.7234 1.90734C16.6257 1.90734 16.539 1.93001 16.4635 1.97535C16.3889 2.01968 16.3305 2.07963 16.2881 2.15519C16.2458 2.22975 16.2242 2.31287 16.2232 2.40455V2.78388C16.2232 2.89874 16.2443 2.99798 16.2866 3.0816C16.33 3.16523 16.3909 3.22971 16.4695 3.27505C16.5481 3.32038 16.6413 3.34305 16.7491 3.34305C16.8206 3.34305 16.8861 3.33298 16.9455 3.31283C17.005 3.29268 17.0559 3.26245 17.0982 3.22215C17.1405 3.18185 17.1727 3.13248 17.1949 3.07405L17.7904 3.11334C17.7601 3.25641 17.6982 3.38134 17.6045 3.48814C17.5118 3.59393 17.3919 3.67654 17.2448 3.73599C17.0987 3.79442 16.9299 3.82364 16.7385 3.82364Z" fill="currentColor"/>
<path d="M12.9878 4.64977V1.45796H13.6225V1.84787H13.6512C13.6795 1.7854 13.7203 1.72193 13.7737 1.65745C13.8281 1.59196 13.8986 1.53755 13.9852 1.49423C14.0729 1.4499 14.1817 1.42773 14.3117 1.42773C14.4809 1.42773 14.6371 1.47207 14.7802 1.56073C14.9232 1.64838 15.0376 1.78087 15.1232 1.95819C15.2089 2.13451 15.2517 2.35566 15.2517 2.62164C15.2517 2.88057 15.2099 3.0992 15.1262 3.27753C15.0436 3.45486 14.9308 3.58936 14.7877 3.68104C14.6457 3.77172 14.4865 3.81706 14.3102 3.81706C14.1852 3.81706 14.0789 3.79641 13.9913 3.7551C13.9046 3.71379 13.8336 3.6619 13.7782 3.59944C13.7228 3.53596 13.6805 3.47199 13.6512 3.4075H13.6316V4.64977H12.9878ZM13.618 2.61862C13.618 2.75665 13.6371 2.87705 13.6754 2.97981C13.7137 3.08258 13.7691 3.16268 13.8417 3.22011C13.9142 3.27653 14.0024 3.30474 14.1061 3.30474C14.2109 3.30474 14.2996 3.27602 14.3721 3.21859C14.4447 3.16016 14.4996 3.07956 14.5368 2.97679C14.5751 2.87302 14.5943 2.75363 14.5943 2.61862C14.5943 2.48462 14.5756 2.36674 14.5384 2.26498C14.5011 2.16322 14.4462 2.08363 14.3736 2.0262C14.3011 1.96877 14.2119 1.94006 14.1061 1.94006C14.0014 1.94006 13.9127 1.96776 13.8402 2.02318C13.7686 2.07859 13.7137 2.15718 13.6754 2.25894C13.6371 2.36069 13.618 2.48059 13.618 2.61862Z" fill="currentColor"/>
<path d="M11.1814 3.82213C11.0333 3.82213 10.9013 3.79644 10.7854 3.74505C10.6696 3.69266 10.5779 3.61559 10.5104 3.51383C10.4439 3.41106 10.4106 3.28311 10.4106 3.12996C10.4106 3.001 10.4343 2.89269 10.4817 2.80504C10.529 2.71739 10.5935 2.64686 10.6751 2.59346C10.7567 2.54006 10.8494 2.49976 10.9532 2.47256C11.058 2.44536 11.1678 2.42621 11.2827 2.41513C11.4177 2.40103 11.5265 2.38793 11.6091 2.37584C11.6917 2.36274 11.7516 2.3436 11.7889 2.31841C11.8262 2.29322 11.8448 2.25594 11.8448 2.20658V2.19751C11.8448 2.10179 11.8146 2.02774 11.7542 1.97535C11.6947 1.92296 11.6101 1.89676 11.5003 1.89676C11.3844 1.89676 11.2922 1.92246 11.2237 1.97384C11.1552 2.02422 11.1099 2.08769 11.0877 2.16426L10.4923 2.1159C10.5225 1.97485 10.5819 1.85294 10.6706 1.75017C10.7592 1.6464 10.8736 1.5668 11.0136 1.51139C11.1547 1.45497 11.3179 1.42676 11.5033 1.42676C11.6323 1.42676 11.7557 1.44187 11.8736 1.4721C11.9924 1.50232 12.0977 1.54917 12.1894 1.61264C12.2821 1.67612 12.3552 1.75773 12.4085 1.85747C12.4619 1.95621 12.4886 2.07459 12.4886 2.21262V3.7783H11.8781V3.4564H11.86C11.8227 3.52894 11.7728 3.59292 11.7103 3.64833C11.6479 3.70274 11.5728 3.74556 11.4852 3.77679C11.3975 3.80702 11.2963 3.82213 11.1814 3.82213ZM11.3658 3.37781C11.4605 3.37781 11.5441 3.35917 11.6166 3.3219C11.6892 3.28361 11.7461 3.23223 11.7874 3.16775C11.8287 3.10327 11.8494 3.03022 11.8494 2.94861V2.70227C11.8292 2.71537 11.8015 2.72746 11.7663 2.73854C11.732 2.74862 11.6932 2.75819 11.6499 2.76726C11.6066 2.77532 11.5632 2.78287 11.5199 2.78993C11.4766 2.79597 11.4373 2.80151 11.402 2.80655C11.3265 2.81763 11.2605 2.83527 11.2041 2.85945C11.1476 2.88363 11.1038 2.91637 11.0726 2.95768C11.0413 2.99798 11.0257 3.04836 11.0257 3.10881C11.0257 3.19646 11.0575 3.26346 11.1209 3.30981C11.1854 3.35514 11.267 3.37781 11.3658 3.37781Z" fill="currentColor"/>
<path d="M8.50757 2.43667V3.77868H7.86377V0.683594H8.48944V1.86692H8.51664C8.56903 1.7299 8.65366 1.6226 8.77053 1.54502C8.88741 1.46643 9.034 1.42714 9.21032 1.42714C9.37152 1.42714 9.51207 1.4624 9.63196 1.53293C9.75286 1.60245 9.84656 1.7027 9.91306 1.83367C9.98056 1.96364 10.0138 2.1193 10.0128 2.30066V3.77868H9.369V2.41551C9.37001 2.27245 9.33374 2.16112 9.26019 2.08152C9.18765 2.00193 9.08589 1.96213 8.95491 1.96213C8.86726 1.96213 8.78968 1.98077 8.72217 2.01805C8.65568 2.05533 8.60329 2.10973 8.565 2.18127C8.52772 2.25179 8.50858 2.33693 8.50757 2.43667Z" fill="currentColor"/>
<path d="M7.40576 2.1199L6.81636 2.15617C6.80629 2.10579 6.78462 2.06045 6.75138 2.02015C6.71813 1.97885 6.6743 1.9461 6.61989 1.92192C6.5665 1.89673 6.50252 1.88414 6.42796 1.88414C6.32822 1.88414 6.24409 1.9053 6.17558 1.94761C6.10707 1.98892 6.07281 2.04433 6.07281 2.11385C6.07281 2.16927 6.09498 2.21612 6.13931 2.2544C6.18364 2.29269 6.25971 2.32342 6.36751 2.34659L6.78765 2.43122C7.01333 2.47757 7.18159 2.55212 7.29241 2.65489C7.40324 2.75766 7.45865 2.89266 7.45865 3.05991C7.45865 3.21205 7.41382 3.34554 7.32415 3.4604C7.23549 3.57526 7.11358 3.66492 6.95842 3.72941C6.80427 3.79288 6.62644 3.82462 6.42494 3.82462C6.11765 3.82462 5.87282 3.76064 5.69046 3.63268C5.50911 3.50372 5.40282 3.32841 5.37158 3.10676L6.00481 3.07351C6.02395 3.16721 6.07029 3.23875 6.14384 3.28811C6.21739 3.33647 6.31159 3.36065 6.42645 3.36065C6.53929 3.36065 6.62997 3.33899 6.69848 3.29567C6.768 3.25134 6.80326 3.19441 6.80427 3.1249C6.80326 3.06646 6.77858 3.0186 6.73022 2.98132C6.68186 2.94304 6.6073 2.91382 6.50655 2.89367L6.10455 2.81357C5.87786 2.76823 5.7091 2.68965 5.59827 2.57781C5.48845 2.46598 5.43354 2.32342 5.43354 2.15012C5.43354 2.00101 5.47384 1.87255 5.55445 1.76475C5.63606 1.65694 5.75041 1.57382 5.89751 1.51539C6.04561 1.45695 6.2189 1.42773 6.41738 1.42773C6.71057 1.42773 6.94129 1.4897 7.10955 1.61362C7.27881 1.73755 7.37755 1.9063 7.40576 2.1199Z" fill="currentColor"/>
<path d="M2.92866 3.82364C2.69391 3.82364 2.49089 3.77377 2.31961 3.67402C2.14934 3.57327 2.01786 3.43323 1.92517 3.25389C1.83248 3.07354 1.78613 2.86448 1.78613 2.62671C1.78613 2.38692 1.83248 2.17736 1.92517 1.99802C2.01786 1.81767 2.14934 1.67763 2.31961 1.57789C2.49089 1.47713 2.69391 1.42676 2.92866 1.42676C3.16341 1.42676 3.36592 1.47713 3.53619 1.57789C3.70747 1.67763 3.83945 1.81767 3.93214 1.99802C4.02483 2.17736 4.07118 2.38692 4.07118 2.62671C4.07118 2.86448 4.02483 3.07354 3.93214 3.25389C3.83945 3.43323 3.70747 3.57327 3.53619 3.67402C3.36592 3.77377 3.16341 3.82364 2.92866 3.82364ZM2.93168 3.32492C3.03848 3.32492 3.12764 3.29469 3.19917 3.23424C3.27071 3.17278 3.32461 3.08916 3.36088 2.98337C3.39816 2.87758 3.4168 2.75718 3.4168 2.62218C3.4168 2.48717 3.39816 2.36677 3.36088 2.26098C3.32461 2.15519 3.27071 2.07157 3.19917 2.01011C3.12764 1.94865 3.03848 1.91792 2.93168 1.91792C2.82387 1.91792 2.7332 1.94865 2.65965 2.01011C2.58711 2.07157 2.5322 2.15519 2.49492 2.26098C2.45865 2.36677 2.44051 2.48717 2.44051 2.62218C2.44051 2.75718 2.45865 2.87758 2.49492 2.98337C2.5322 3.08916 2.58711 3.17278 2.65965 3.23424C2.7332 3.29469 2.82387 3.32492 2.93168 3.32492Z" fill="currentColor"/>
<path d="M1.48533 1.45752V1.94112H0.0874023L0.0874023 1.45752H1.48533ZM0.40477 0.901367H1.04857V3.06551C1.04857 3.12496 1.05764 3.1713 1.07578 3.20455C1.09391 3.23679 1.1191 3.25946 1.15134 3.27256C1.18459 3.28565 1.22287 3.2922 1.2662 3.2922C1.29642 3.2922 1.32665 3.28968 1.35687 3.28465C1.3871 3.2786 1.41027 3.27407 1.42639 3.27105L1.52765 3.75012C1.49541 3.76019 1.45007 3.77178 1.39163 3.78488C1.3332 3.79898 1.26217 3.80755 1.17854 3.81057C1.02339 3.81662 0.88737 3.79596 0.770499 3.74861C0.654634 3.70126 0.564461 3.62771 0.49998 3.52796C0.435499 3.42822 0.403763 3.30228 0.40477 3.15014V0.901367Z" fill="currentColor"/>
</svg>
`;
@@ -0,0 +1,151 @@
import {
BulletedListIcon,
CheckBoxIcon,
CodeBlockIcon,
DividerIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
NumberedListIcon,
QuoteIcon,
TextIcon,
} from '@blocksuite/affine-components/icons';
import type { NoteChildrenFlavour } from '@blocksuite/affine-shared/types';
import type { TemplateResult } from 'lit';
export const BUTTON_GROUP_LENGTH = 10;
export type NoteMenuItem = {
icon: TemplateResult<1>;
tooltip: string;
childFlavour: NoteChildrenFlavour;
childType: string | null;
};
const LIST_ITEMS = [
{
flavour: 'affine:list',
type: 'bulleted',
name: 'Bulleted List',
description: 'A simple bulleted list.',
icon: BulletedListIcon,
tooltip: 'Drag/Click to insert Bulleted List',
},
{
flavour: 'affine:list',
type: 'numbered',
name: 'Numbered List',
description: 'A list with numbering.',
icon: NumberedListIcon,
tooltip: 'Drag/Click to insert Numbered List',
},
{
flavour: 'affine:list',
type: 'todo',
name: 'To-do List',
description: 'Track tasks with a to-do list.',
icon: CheckBoxIcon,
tooltip: 'Drag/Click to insert To-do List',
},
];
const TEXT_ITEMS = [
{
flavour: 'affine:paragraph',
type: 'text',
name: 'Text',
description: 'Start typing with plain text.',
icon: TextIcon,
tooltip: 'Drag/Click to insert Text block',
},
{
flavour: 'affine:paragraph',
type: 'h1',
name: 'Heading 1',
description: 'Headings in the largest font.',
icon: Heading1Icon,
tooltip: 'Drag/Click to insert Heading 1',
},
{
flavour: 'affine:paragraph',
type: 'h2',
name: 'Heading 2',
description: 'Headings in the 2nd font size.',
icon: Heading2Icon,
tooltip: 'Drag/Click to insert Heading 2',
},
{
flavour: 'affine:paragraph',
type: 'h3',
name: 'Heading 3',
description: 'Headings in the 3rd font size.',
icon: Heading3Icon,
tooltip: 'Drag/Click to insert Heading 3',
},
{
flavour: 'affine:paragraph',
type: 'h4',
name: 'Heading 4',
description: 'Heading in the 4th font size.',
icon: Heading4Icon,
tooltip: 'Drag/Click to insert Heading 4',
},
{
flavour: 'affine:paragraph',
type: 'h5',
name: 'Heading 5',
description: 'Heading in the 5th font size.',
icon: Heading5Icon,
tooltip: 'Drag/Click to insert Heading 5',
},
{
flavour: 'affine:paragraph',
type: 'h6',
name: 'Heading 6',
description: 'Heading in the 6th font size.',
icon: Heading6Icon,
tooltip: 'Drag/Click to insert Heading 6',
},
{
flavour: 'affine:code',
type: 'code',
name: 'Code Block',
description: 'Capture a code snippet.',
icon: CodeBlockIcon,
tooltip: 'Drag/Click to insert Code Block',
},
{
flavour: 'affine:paragraph',
type: 'quote',
name: 'Quote',
description: 'Capture a quote.',
icon: QuoteIcon,
tooltip: 'Drag/Click to insert Quote',
},
{
flavour: 'affine:divider',
type: null,
name: 'Divider',
description: 'A visual divider.',
icon: DividerIcon,
tooltip: 'A visual divider',
},
];
// TODO: add image, bookmark, database blocks
export const NOTE_MENU_ITEMS = TEXT_ITEMS.concat(LIST_ITEMS)
.filter(item => item.name !== 'Divider')
.map(item => {
return {
icon: item.icon,
tooltip:
item.type !== 'text'
? item.tooltip.replace('Drag/Click to insert ', '')
: 'Text',
childFlavour: item.flavour as NoteChildrenFlavour,
childType: item.type,
} as NoteMenuItem;
});
@@ -0,0 +1,212 @@
import { addAttachments } from '@blocksuite/affine-block-attachment';
import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmark';
import { addImages, LoadedImageIcon } from '@blocksuite/affine-block-image';
import { AttachmentIcon, LinkIcon } from '@blocksuite/affine-components/icons';
import { MAX_IMAGE_WIDTH } from '@blocksuite/affine-model';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type { NoteChildrenFlavour } from '@blocksuite/affine-shared/types';
import {
getImageFilesFromLocal,
openFileOrFiles,
} from '@blocksuite/affine-shared/utils';
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import { effect } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { NoteToolOption } from '../../../gfx-tool/note-tool.js';
import { getTooltipWithShortcut } from '../../utils.js';
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
import { NOTE_MENU_ITEMS } from './note-menu-config.js';
export class EdgelessNoteMenu extends EdgelessToolbarToolMixin(LitElement) {
static override styles = css`
:host {
position: absolute;
display: flex;
z-index: -1;
}
.menu-content {
display: flex;
align-items: center;
justify-content: center;
}
.button-group-container {
display: flex;
justify-content: center;
align-items: center;
gap: 14px;
fill: var(--affine-icon-color);
}
.button-group-container svg {
width: 20px;
height: 20px;
}
.divider {
width: 1px;
height: 24px;
background: var(--affine-border-color);
transform: scaleX(0.5);
margin: 0 14px;
}
`;
override type: GfxToolsFullOptionValue['type'] = 'affine:note';
private async _addImages() {
this._imageLoading = true;
const imageFiles = await getImageFilesFromLocal();
const ids = await addImages(this.edgeless.std, imageFiles, {
maxWidth: MAX_IMAGE_WIDTH,
});
this._imageLoading = false;
this.edgeless.gfx.tool.setTool('default');
this.edgeless.gfx.selection.set({ elements: ids });
}
private _onHandleLinkButtonClick() {
const [_, { insertedLinkType }] = this.edgeless.service.std.command.exec(
insertLinkByQuickSearchCommand
);
insertedLinkType
?.then(type => {
const flavour = type?.flavour;
if (!flavour) return;
this.edgeless.std
.getOptional(TelemetryProvider)
?.track('CanvasElementAdded', {
control: 'toolbar:general',
page: 'whiteboard editor',
module: 'toolbar',
type: flavour.split(':')[1],
});
})
.catch(console.error);
}
override disconnectedCallback() {
super.disconnectedCallback();
}
override firstUpdated() {
this.disposables.add(
effect(() => {
const tool = this.edgeless.gfx.tool.currentToolOption$.value;
if (tool?.type !== 'affine:note') return;
this.childFlavour = tool.childFlavour;
this.childType = tool.childType;
this.tip = tool.tip;
})
);
}
override render() {
const { childType } = this;
return html`
<edgeless-slide-menu>
<div class="menu-content">
<!-- add to edgeless -->
<div class="button-group-container">
<edgeless-tool-icon-button
.activeMode=${'background'}
.tooltip=${'Image'}
@click=${this._addImages}
.disabled=${this._imageLoading}
>
${LoadedImageIcon}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.activeMode=${'background'}
.tooltip=${getTooltipWithShortcut('Link', '@')}
@click=${() => {
this._onHandleLinkButtonClick();
}}
>
${LinkIcon}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.activeMode=${'background'}
.tooltip=${'File'}
@click=${async () => {
const file = await openFileOrFiles();
if (!file) return;
await addAttachments(this.edgeless.std, [file]);
this.edgeless.gfx.tool.setTool('default');
this.edgeless.std
.getOptional(TelemetryProvider)
?.track('CanvasElementAdded', {
control: 'toolbar:general',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'attachment',
});
}}
>
${AttachmentIcon}
</edgeless-tool-icon-button>
</div>
<div class="divider"></div>
<!-- add to note -->
<div class="button-group-container">
${repeat(
NOTE_MENU_ITEMS,
item => item.childFlavour,
item => html`
<edgeless-tool-icon-button
.active=${childType === item.childType}
.activeMode=${'background'}
.tooltip=${item.tooltip}
@click=${() =>
this.onChange({
childFlavour: item.childFlavour,
childType: item.childType,
tip: item.tooltip,
})}
>
${item.icon}
</edgeless-tool-icon-button>
`
)}
</div>
</div>
</edgeless-slide-menu>
`;
}
@state()
private accessor _imageLoading = false;
@property({ attribute: false })
accessor childFlavour!: NoteChildrenFlavour;
@property({ attribute: false })
accessor childType!: string | null;
@property({ attribute: false })
accessor onChange!: (
props: Partial<{
childFlavour: NoteToolOption['childFlavour'];
childType: string | null;
tip: string;
}>
) => void;
@property({ attribute: false })
accessor tip!: string;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-note-menu': EdgelessNoteMenu;
}
}
@@ -0,0 +1,216 @@
import {
Heading1Icon,
LinkIcon,
TextIcon,
} from '@blocksuite/affine-components/icons';
import {
EditPropsStore,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { SignalWatcher } from '@blocksuite/global/utils';
import { computed } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { state } from 'lit/decorators.js';
import type { NoteToolOption } from '../../../gfx-tool/note-tool.js';
import { getTooltipWithShortcut } from '../../utils.js';
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
import { toShapeNotToAdapt } from './icon.js';
export class EdgelessNoteSeniorButton extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host,
.edgeless-note-button {
display: block;
width: 100%;
height: 100%;
}
:host * {
box-sizing: border-box;
}
.note-root[data-app-theme='light'] {
--paper-border-color: var(--affine-pure-white);
--paper-foriegn-color: rgba(0, 0, 0, 0.1);
--paper-shadow: 0px 2px 4px rgba(0, 0, 0, 0.25);
--icon-card-bg: #fff;
--icon-card-shadow:
0px 2px 4px rgba(0, 0, 0, 0.22), inset 0px -2px 1px rgba(0, 0, 0, 0.14);
}
.note-root[data-app-theme='dark'] {
--paper-border-color: var(--affine-divider-color);
--paper-foriegn-color: rgba(255, 255, 255, 0.12);
--paper-shadow: 0px 2px 6px rgba(0, 0, 0, 0.8);
--icon-card-bg: #343434;
--icon-card-shadow:
0px 2px 4px rgba(0, 0, 0, 0.6),
inset 0px -2px 1px rgba(255, 255, 255, 0.06);
}
.note-root {
width: 100%;
height: 64px;
background: transparent;
position: relative;
overflow: hidden;
cursor: pointer;
display: flex;
align-items: flex-end;
justify-content: center;
}
.paper {
--y: 20px;
--r: 4.42deg;
width: 60px;
height: 72px;
background: var(--paper-bg);
border: 1px solid var(--paper-border-color);
position: absolute;
transform: translateY(var(--y)) rotate(var(--r));
color: var(--paper-foriegn-color);
box-shadow: var(--paper-shadow);
padding-top: 32px;
padding-left: 3px;
transition: transform 0.4s ease;
}
.edgeless-toolbar-note-icon {
position: absolute;
width: 26px;
height: 26px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
color: var(--affine-icon-secondary);
background: var(--icon-card-bg);
box-shadow: var(--icon-card-shadow);
bottom: 12px;
transition: transform 0.4s ease;
transform: translateX(var(--x)) translateY(var(--y)) rotate(var(--r));
}
.edgeless-toolbar-note-icon.link {
--x: -22px;
--y: -5px;
--r: -6deg;
transform-origin: 0% 100%;
}
.edgeless-toolbar-note-icon.text {
--r: 4deg;
--x: 0px;
--y: 0px;
}
.edgeless-toolbar-note-icon.heading {
--x: 21px;
--y: -7px;
--r: 8deg;
transform-origin: 0% 100%;
}
.note-root:hover .paper {
--y: 15px;
}
.note-root:hover .link {
--x: -25px;
--y: -5px;
--r: -9.5deg;
}
.note-root:hover .text {
--y: -10px;
}
.note-root:hover .heading {
--x: 23px;
--y: -8px;
--r: 15deg;
}
`;
private readonly _noteBg$ = computed(() => {
return this.edgeless.std
.get(ThemeProvider)
.generateColorProperty(
this.edgeless.std.get(EditPropsStore).lastProps$.value['affine:note']
.background
);
});
private readonly _states = ['childFlavour', 'childType', 'tip'] as const;
override enableActiveBackground = true;
override type = 'affine:note' as const;
private _toggleNoteMenu() {
if (this.tryDisposePopper()) return;
const { edgeless, childFlavour, childType, tip } = this;
this.setEdgelessTool({
type: 'affine:note',
childFlavour,
childType,
tip,
});
const menu = this.createPopper('edgeless-note-menu', this);
Object.assign(menu.element, {
edgeless,
childFlavour,
childType,
tip,
onChange: (
props: Partial<{
childFlavour: NoteToolOption['childFlavour'];
childType: string | null;
tip: string;
}>
) => {
this._states.forEach(key => {
// oxlint-disable-next-line eqeqeq
if (props[key] != undefined) {
Object.assign(this, { [key]: props[key] });
}
});
this.setEdgelessTool({
type: 'affine:note',
childFlavour: this.childFlavour,
childType: this.childType,
tip: this.tip,
});
},
});
}
override render() {
const appTheme = this.edgeless.std.get(ThemeProvider).app$.value;
return html`<edgeless-toolbar-button
class="edgeless-note-button"
.tooltip=${this.popper ? '' : getTooltipWithShortcut('Note', 'N')}
.tooltipOffset=${5}
>
<div
class="note-root"
data-app-theme=${appTheme}
@click=${this._toggleNoteMenu}
style="--paper-bg: ${this._noteBg$.value}"
>
<div class="paper">${toShapeNotToAdapt}</div>
<div class="edgeless-toolbar-note-icon link">${LinkIcon}</div>
<div class="edgeless-toolbar-note-icon heading">${Heading1Icon}</div>
<div class="edgeless-toolbar-note-icon text">${TextIcon}</div>
</div>
</edgeless-toolbar-button>`;
}
// TODO: better to extract these states outside of component?
@state()
accessor childFlavour: NoteToolOption['childFlavour'] = 'affine:paragraph';
@state()
accessor childType = 'text';
@state()
accessor tip = 'Note';
}
@@ -0,0 +1,120 @@
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import { PageIcon } from '@blocksuite/icons/lit';
import { effect } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { state } from 'lit/decorators.js';
import { getTooltipWithShortcut } from '../../../components/utils.js';
import type { NoteToolOption } from '../../../gfx-tool/note-tool.js';
import { createPopper, type MenuPopper } from '../common/create-popper.js';
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
import type { EdgelessNoteMenu } from './note-menu.js';
export class EdgelessNoteToolButton extends QuickToolMixin(LitElement) {
static override styles = css`
:host {
display: flex;
}
`;
private _noteMenu: MenuPopper<EdgelessNoteMenu> | null = null;
private readonly _states = ['childFlavour', 'childType', 'tip'] as const;
override type: GfxToolsFullOptionValue['type'] = 'affine:note';
private _disposeMenu() {
this._noteMenu?.dispose();
this._noteMenu = null;
}
private _toggleNoteMenu() {
if (this._noteMenu) {
this._disposeMenu();
this.requestUpdate();
} else {
this.edgeless.gfx.tool.setTool('affine:note', {
childFlavour: this.childFlavour,
childType: this.childType,
tip: this.tip,
});
this._noteMenu = createPopper('edgeless-note-menu', this);
this._noteMenu.element.edgeless = this.edgeless;
this._noteMenu.element.childFlavour = this.childFlavour;
this._noteMenu.element.childType = this.childType;
this._noteMenu.element.tip = this.tip;
this._noteMenu.element.onChange = (
props: Partial<{
childFlavour: NoteToolOption['childFlavour'];
childType: string | null;
tip: string;
}>
) => {
this._states.forEach(key => {
// oxlint-disable-next-line eqeqeq
if (props[key] != undefined) {
Object.assign(this, { [key]: props[key] });
}
});
this.edgeless.gfx.tool.setTool('affine:note', {
childFlavour: this.childFlavour,
childType: this.childType,
tip: this.tip,
});
};
}
}
override connectedCallback() {
super.connectedCallback();
this._disposables.add(
effect(() => {
const value = this.edgeless.gfx.tool.currentToolName$.value;
if (value !== 'affine:note') {
this._disposeMenu();
}
})
);
}
override disconnectedCallback() {
this._disposeMenu();
super.disconnectedCallback();
}
override render() {
const { active } = this;
return html`
<edgeless-tool-icon-button
class="edgeless-note-button"
.tooltip=${this._noteMenu ? '' : getTooltipWithShortcut('Note', 'N')}
.tooltipOffset=${17}
.active=${active}
.iconContainerPadding=${6}
.iconSize=${'24px'}
@click=${() => {
this._toggleNoteMenu();
}}
>
${PageIcon()}
<toolbar-arrow-up-icon></toolbar-arrow-up-icon>
</edgeless-tool-icon-button>
`;
}
@state()
accessor childFlavour: NoteToolOption['childFlavour'] = 'affine:paragraph';
@state()
accessor childType = 'text';
@state()
accessor tip = 'Text';
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-note-tool-button': EdgelessNoteToolButton;
}
}
@@ -0,0 +1,84 @@
import type { FrameBlockModel } from '@blocksuite/affine-model';
import { createButtonPopper } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/utils';
import { LayerIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
import type { EdgelessFrameOrderMenu } from './frame-order-menu.js';
export class EdgelessFrameOrderButton extends WithDisposable(LitElement) {
static override styles = css`
edgeless-frame-order-menu {
display: none;
}
edgeless-frame-order-menu[data-show] {
display: initial;
}
`;
private _edgelessFrameOrderPopper: ReturnType<
typeof createButtonPopper
> | null = null;
override disconnectedCallback() {
super.disconnectedCallback();
this._edgelessFrameOrderPopper?.dispose();
}
override firstUpdated() {
this._edgelessFrameOrderPopper = createButtonPopper(
this._edgelessFrameOrderButton,
this._edgelessFrameOrderMenu,
({ display }) => this.setPopperShow(display === 'show'),
{
mainAxis: 22,
}
);
}
protected override render() {
const { readonly } = this.edgeless.doc;
return html`
<style>
.edgeless-frame-order-button svg {
color: ${readonly ? 'var(--affine-text-disable-color)' : 'inherit'};
}
</style>
<edgeless-tool-icon-button
class="edgeless-frame-order-button"
.iconSize=${'24px'}
.tooltip=${this.popperShow ? '' : 'Frame Order'}
@click=${() => {
if (readonly) return;
this._edgelessFrameOrderPopper?.toggle();
}}
.iconContainerPadding=${0}
>
${LayerIcon()}
</edgeless-tool-icon-button>
<edgeless-frame-order-menu .edgeless=${this.edgeless}>
</edgeless-frame-order-menu>
`;
}
@query('.edgeless-frame-order-button')
private accessor _edgelessFrameOrderButton!: HTMLElement;
@query('edgeless-frame-order-menu')
private accessor _edgelessFrameOrderMenu!: EdgelessFrameOrderMenu;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor frames!: FrameBlockModel[];
@property({ attribute: false })
accessor popperShow = false;
@property({ attribute: false })
accessor setPopperShow: (show: boolean) => void = () => {};
}
@@ -0,0 +1,268 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import { generateKeyBetweenV2 } from '@blocksuite/block-std/gfx';
import {
DisposableGroup,
SignalWatcher,
WithDisposable,
} from '@blocksuite/global/utils';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
export class EdgelessFrameOrderMenu extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = css`
:host {
position: relative;
}
.edgeless-frame-order-items-container {
max-height: 281px;
border-radius: 8px;
padding: 8px;
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-menu-shadow);
overflow: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.edgeless-frame-order-items-container.embed {
padding: 0;
background: unset;
box-shadow: unset;
border-radius: 0;
}
.item {
box-sizing: border-box;
width: 256px;
border-radius: 4px;
padding: 4px;
display: flex;
gap: 4px;
align-items: center;
cursor: grab;
}
.draggable:hover {
background-color: var(--affine-hover-color);
}
.item:hover .drag-indicator {
opacity: 1;
}
.drag-indicator {
cursor: pointer;
width: 4px;
height: 12px;
border-radius: 1px;
opacity: 0.2;
background: var(--affine-placeholder-color);
margin-right: 2px;
}
.title {
font-size: 14px;
font-weight: 400;
height: 22px;
line-height: 22px;
color: var(--affine-text-primary-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.clone {
visibility: hidden;
position: absolute;
z-index: 1;
left: 8px;
height: 30px;
border: 1px solid var(--affine-border-color);
box-shadow: var(--affine-menu-shadow);
background-color: var(--affine-white);
pointer-events: none;
}
.indicator-line {
visibility: hidden;
position: absolute;
z-index: 1;
left: 8px;
background-color: var(--affine-primary-color);
height: 1px;
width: 90%;
}
`;
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private get _frames() {
return this.edgeless.service.frames;
}
private _bindEvent() {
const { _disposables } = this;
_disposables.addFromEvent(this._container, 'wheel', e => {
e.stopPropagation();
});
_disposables.addFromEvent(this._container, 'pointerdown', e => {
const ele = e.target as HTMLElement;
const draggable = ele.closest('.draggable');
if (!draggable) return;
const clone = this._clone;
const indicatorLine = this._indicatorLine;
clone.style.visibility = 'visible';
const rect = draggable.getBoundingClientRect();
const index = Number(draggable.getAttribute('index'));
this._curIndex = index;
let newIndex = -1;
const containerRect = this._container.getBoundingClientRect();
const start = containerRect.top + 8;
const end = containerRect.bottom;
const shiftX = e.clientX - rect.left;
const shiftY = e.clientY - rect.top;
function moveAt(x: number, y: number) {
clone.style.left = x - containerRect.left - shiftX + 'px';
clone.style.top = y - containerRect.top - shiftY + 'px';
}
function isInsideContainer(e: PointerEvent) {
return e.clientY >= start && e.clientY <= end;
}
moveAt(e.clientX, e.clientY);
this._disposables.addFromEvent(document, 'pointermove', e => {
indicatorLine.style.visibility = 'visible';
moveAt(e.clientX, e.clientY);
if (isInsideContainer(e)) {
const relativeY = e.pageY + this._container.scrollTop - start;
let top = 0;
if (relativeY < rect.height / 2) {
newIndex = 0;
top = this.embed ? -2 : 4;
} else {
newIndex = Math.ceil(
(relativeY - rect.height / 2) / (rect.height + 10)
);
top =
(this.embed ? -2 : 7.5) +
newIndex * rect.height +
(newIndex - 0.5) * 4;
}
indicatorLine.style.top = top - this._container.scrollTop + 'px';
return;
}
newIndex = -1;
});
this._disposables.addFromEvent(document, 'pointerup', () => {
clone.style.visibility = 'hidden';
indicatorLine.style.visibility = 'hidden';
if (
newIndex >= 0 &&
newIndex <= this._frames.length &&
newIndex !== index &&
newIndex !== index + 1
) {
const frameMgr = this.edgeless.service.frame;
// Legacy compatibility
frameMgr.refreshLegacyFrameOrder();
const before = this._frames[newIndex - 1]?.presentationIndex || null;
const after = this._frames[newIndex]?.presentationIndex || null;
const frame = this._frames[index];
this.crud.updateElement(frame.id, {
presentationIndex: generateKeyBetweenV2(before, after),
});
this.edgeless.doc.captureSync();
this.requestUpdate();
}
this._disposables.dispose();
this._disposables = new DisposableGroup();
this._bindEvent();
});
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this._disposables.dispose();
}
override firstUpdated() {
this._bindEvent();
}
override render() {
const frame = this._frames[this._curIndex];
return html`
<div
class="edgeless-frame-order-items-container ${this.embed
? 'embed'
: ''}"
@click=${(e: MouseEvent) => e.stopPropagation()}
>
${repeat(
this._frames,
frame => frame.id,
(frame, index) => html`
<div class="item draggable" id=${frame.id} index=${index}>
<div class="drag-indicator"></div>
<div class="title">${frame.title.toString()}</div>
</div>
`
)}
<div class="indicator-line"></div>
<div class="clone item">
${frame
? html`<div class="drag-indicator"></div>
<div class="index">${this._curIndex + 1}</div>
<div class="title">${frame.title.toString()}</div>`
: nothing}
</div>
</div>
`;
}
@query('.clone')
private accessor _clone!: HTMLDivElement;
@query('.edgeless-frame-order-items-container')
private accessor _container!: HTMLDivElement;
@state()
private accessor _curIndex = -1;
@query('.indicator-line')
private accessor _indicatorLine!: HTMLDivElement;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor embed = false;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-frame-order-menu': EdgelessFrameOrderMenu;
}
}
@@ -0,0 +1,203 @@
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import { createButtonPopper } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/utils';
import { SettingsIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
export class EdgelessNavigatorSettingButton extends WithDisposable(LitElement) {
static override styles = css`
.navigator-setting-menu {
display: none;
padding: 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
background-color: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-menu-shadow);
color: var(--affine-text-primary-color);
}
.navigator-setting-menu[data-show] {
display: flex;
flex-direction: column;
gap: 4px;
}
.item-container {
padding: 4px 12px;
display: flex;
justify-content: space-between;
align-items: center;
min-width: 264px;
width: 100%;
box-sizing: border-box;
}
.item-container.header {
height: 34px;
}
.text {
padding: 0px 4px;
line-height: 22px;
font-size: var(--affine-font-sm);
color: var(--affine-text-primary-color);
}
.text.title {
font-weight: 500;
line-height: 20px;
font-size: var(--affine-font-xs);
color: var(--affine-text-secondary-color);
}
.divider {
width: 100%;
height: 16px;
display: flex;
align-items: center;
}
.divider::before {
content: '';
width: 100%;
height: 1px;
background: var(--affine-border-color);
}
`;
private _navigatorSettingPopper?: ReturnType<
typeof createButtonPopper
> | null = null;
private readonly _onBlackBackgroundChange = (checked: boolean) => {
this.blackBackground = checked;
this.edgeless.slots.navigatorSettingUpdated.emit({
blackBackground: this.blackBackground,
});
};
private _tryRestoreSettings() {
const blackBackground = this.edgeless.std
.get(EditPropsStore)
.getStorage('presentBlackBackground');
this.blackBackground = blackBackground ?? true;
}
override connectedCallback() {
super.connectedCallback();
this._tryRestoreSettings();
}
override disconnectedCallback(): void {
this._navigatorSettingPopper?.dispose();
this._navigatorSettingPopper = null;
}
override firstUpdated() {
this._navigatorSettingPopper = createButtonPopper(
this._navigatorSettingButton,
this._navigatorSettingMenu,
({ display }) => this.setPopperShow(display === 'show'),
{
mainAxis: 22,
}
);
}
override render() {
return html`
<edgeless-tool-icon-button
class="navigator-setting-button"
.tooltip=${this.popperShow ? '' : 'Settings'}
.iconSize=${'24px'}
@click=${() => {
this._navigatorSettingPopper?.toggle();
}}
.iconContainerPadding=${0}
>
${SettingsIcon()}
</edgeless-tool-icon-button>
<div
class="navigator-setting-menu"
@click=${(e: MouseEvent) => {
e.stopPropagation();
}}
>
<div class="item-container header">
<div class="text title">Playback Settings</div>
</div>
<div class="item-container">
<div class="text">Black background</div>
<toggle-switch
.on=${this.blackBackground}
.onChange=${this._onBlackBackgroundChange}
>
</toggle-switch>
</div>
<div class="item-container">
<div class="text">Hide toolbar</div>
<toggle-switch
.on=${this.hideToolbar}
.onChange=${(checked: boolean) => {
this.onHideToolbarChange && this.onHideToolbarChange(checked);
}}
>
</toggle-switch>
</div>
${this.includeFrameOrder
? html` <div class="divider"></div>
<div class="item-container header">
<div class="text title">Frame Order</div>
</div>
<edgeless-frame-order-menu
.edgeless=${this.edgeless}
.embed=${true}
></edgeless-frame-order-menu>`
: nothing}
</div>
`;
}
@query('.navigator-setting-button')
private accessor _navigatorSettingButton!: HTMLElement;
@query('.navigator-setting-menu')
private accessor _navigatorSettingMenu!: HTMLElement;
@state()
accessor blackBackground = true;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor hideToolbar = false;
@property({ attribute: false })
accessor includeFrameOrder = false;
@property({ attribute: false })
accessor onHideToolbarChange: undefined | ((hideToolbar: boolean) => void) =
undefined;
@property({ attribute: false })
accessor popperShow = false;
@property({ attribute: false })
accessor setPopperShow: (show: boolean) => void = () => {};
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-navigator-setting-button': EdgelessNavigatorSettingButton;
}
}
@@ -0,0 +1,46 @@
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import { PresentationIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
export class EdgelessPresentButton extends QuickToolMixin(
EdgelessToolbarToolMixin(LitElement)
) {
static override styles = css`
:host {
display: flex;
}
.edgeless-note-button {
display: flex;
position: relative;
}
`;
override type: GfxToolsFullOptionValue['type'] = 'frameNavigator';
override render() {
return html`<edgeless-tool-icon-button
class="edgeless-frame-navigator-button"
.tooltip=${'Present'}
.tooltipOffset=${17}
.iconContainerPadding=${6}
.iconSize=${'24px'}
@click=${() => {
this.setEdgelessTool({
type: 'frameNavigator',
});
}}
>
${PresentationIcon()}
</edgeless-tool-icon-button>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-present-button': EdgelessPresentButton;
}
}
@@ -0,0 +1,454 @@
import {
isFrameBlock,
type NavigatorMode,
} from '@blocksuite/affine-block-frame';
import { toast } from '@blocksuite/affine-components/toast';
import type { FrameBlockModel } from '@blocksuite/affine-model';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
import { Bound, clamp, SignalWatcher } from '@blocksuite/global/utils';
import {
EndPointArrowIcon,
ExpandCloseIcon,
ExpandFullIcon,
StartPointArrowIcon,
StopAiIcon,
} from '@blocksuite/icons/lit';
import { effect } from '@preact/signals-core';
import { cssVar } from '@toeverything/theme';
import { css, html, LitElement, nothing, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
import { launchIntoFullscreen } from '../utils.js';
import { EdgelessToolbarToolMixin } from './mixins/tool.mixin.js';
export class PresentationToolbar extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
align-items: inherit;
width: 100%;
height: 100%;
gap: 8px;
padding-right: 2px;
}
.full-divider {
width: 8px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.full-divider::after {
content: '';
width: 1px;
height: 100%;
background: var(--affine-border-color);
transform: scaleX(0.5);
}
.config-buttons {
display: flex;
gap: 10px;
}
.edgeless-frame-navigator {
width: 140px;
display: flex;
align-items: center;
justify-content: center;
}
.edgeless-frame-navigator.dense {
width: auto;
}
.edgeless-frame-navigator-title {
display: inline-block;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 8px;
}
.edgeless-frame-navigator-count {
color: var(--affine-text-secondary-color);
white-space: nowrap;
}
.edgeless-frame-navigator-stop {
border: none;
cursor: pointer;
padding: 4px;
border-radius: 8px;
position: relative;
overflow: hidden;
svg {
display: block;
width: 24px;
height: 24px;
color: white;
}
}
.edgeless-frame-navigator-stop::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: transparent;
border-radius: inherit;
}
.edgeless-frame-navigator-stop:hover::before {
background: var(--affine-hover-color);
}
`;
private _cachedIndex = -1;
private _timer?: ReturnType<typeof setTimeout>;
override type: GfxToolsFullOptionValue['type'] = 'frameNavigator';
private get _cachedPresentHideToolbar() {
return !!this.edgeless.std
.get(EditPropsStore)
.getStorage('presentHideToolbar');
}
private set _cachedPresentHideToolbar(value) {
this.edgeless.std
.get(EditPropsStore)
.setStorage('presentHideToolbar', !!value);
}
private get _frames(): FrameBlockModel[] {
return this.edgeless.service.frames;
}
get dense() {
return this.containerWidth < 554;
}
get host() {
return this.edgeless.host;
}
constructor(edgeless: EdgelessRootBlockComponent) {
super();
this.edgeless = edgeless;
}
private _bindHotKey() {
const handleKeyIfFrameNavigator = (action: () => void) => () => {
if (this.edgelessTool.type === 'frameNavigator') {
action();
}
};
this.edgeless.bindHotKey(
{
ArrowLeft: handleKeyIfFrameNavigator(() => this._previousFrame()),
ArrowRight: handleKeyIfFrameNavigator(() => this._nextFrame()),
Escape: handleKeyIfFrameNavigator(() => this._exitPresentation()),
},
{
global: true,
}
);
}
private _exitPresentation() {
// When exit presentation mode, we need to set the tool to default or pan
// And exit fullscreen
this.setEdgelessTool(
this.edgeless.doc.readonly
? { type: 'pan', panning: false }
: { type: 'default' }
);
if (document.fullscreenElement) {
document.exitFullscreen().catch(console.error);
}
}
private _moveToCurrentFrame() {
const current = this._currentFrameIndex;
const viewport = this.edgeless.service.viewport;
const frame = this._frames[current];
if (frame) {
let bound = Bound.deserialize(frame.xywh);
if (this._navigatorMode === 'fill') {
const vb = viewport.viewportBounds;
const center = bound.center;
let w, h;
if (vb.w / vb.h > bound.w / bound.h) {
w = bound.w;
h = (w * vb.h) / vb.w;
} else {
h = bound.h;
w = (h * vb.w) / vb.h;
}
bound = Bound.fromCenter(center, w, h);
}
viewport.setViewportByBound(bound, [0, 0, 0, 0], false);
this.edgeless.slots.navigatorFrameChanged.emit(
this._frames[this._currentFrameIndex]
);
}
}
private _nextFrame() {
const frames = this._frames;
const min = 0;
const max = frames.length - 1;
if (this._currentFrameIndex === frames.length - 1) {
toast(this.host, 'You have reached the last frame');
} else {
this._currentFrameIndex = clamp(this._currentFrameIndex + 1, min, max);
}
}
private _previousFrame() {
const frames = this._frames;
const min = 0;
const max = frames.length - 1;
if (this._currentFrameIndex === 0) {
toast(this.host, 'You have reached the first frame');
} else {
this._currentFrameIndex = clamp(this._currentFrameIndex - 1, min, max);
}
}
/**
* Toggle fullscreen, but keep edgeless tool to frameNavigator
* If already fullscreen, exit fullscreen
* If not fullscreen, enter fullscreen
*/
private _toggleFullScreen() {
if (document.fullscreenElement) {
document.exitFullscreen().catch(console.error);
this._fullScreenMode = false;
} else {
launchIntoFullscreen(this.edgeless.viewportElement);
this._fullScreenMode = true;
}
}
override connectedCallback(): void {
super.connectedCallback();
const { _disposables, edgeless } = this;
_disposables.add(
effect(() => {
const currentTool = this.edgeless.gfx.tool.currentToolOption$.value;
if (currentTool?.type === 'frameNavigator') {
this._cachedIndex = this._currentFrameIndex;
this._navigatorMode = currentTool.mode ?? this._navigatorMode;
if (isFrameBlock(edgeless.service.selection.selectedElements[0])) {
this._cachedIndex = this._frames.findIndex(
frame =>
frame.id === edgeless.service.selection.selectedElements[0].id
);
}
if (this._frames.length === 0)
toast(
this.host,
'The presentation requires at least 1 frame. You can firstly create a frame.',
5000
);
this._toggleFullScreen();
}
this.requestUpdate();
})
);
}
override firstUpdated() {
const { _disposables, edgeless } = this;
this._bindHotKey();
_disposables.add(
edgeless.slots.navigatorSettingUpdated.on(({ fillScreen }) => {
if (fillScreen !== undefined) {
this._navigatorMode = fillScreen ? 'fill' : 'fit';
}
})
);
_disposables.addFromEvent(document, 'fullscreenchange', () => {
if (document.fullscreenElement) {
// When enter fullscreen, we need to set current frame to the cached index
this._timer = setTimeout(() => {
this._currentFrameIndex = this._cachedIndex;
}, 400);
} else {
// When exit fullscreen, we need to clear the timer
clearTimeout(this._timer);
if (
this.edgelessTool.type === 'frameNavigator' &&
this._fullScreenMode
) {
this.setEdgelessTool(
this.edgeless.doc.readonly
? { type: 'pan', panning: false }
: { type: 'default' }
);
}
}
setTimeout(() => this._moveToCurrentFrame(), 400);
this.edgeless.slots.fullScreenToggled.emit();
});
this._navigatorMode =
this.edgeless.std.get(EditPropsStore).getStorage('presentFillScreen') ===
true
? 'fill'
: 'fit';
}
override render() {
const current = this._currentFrameIndex;
const frames = this._frames;
const frame = frames[current];
return html`
<style>
:host {
display: flex;
}
</style>
<edgeless-tool-icon-button
.iconContainerPadding=${0}
.tooltip=${'Previous'}
.iconSize=${'24px'}
@click=${() => this._previousFrame()}
>
${StartPointArrowIcon()}
</edgeless-tool-icon-button>
<div class="edgeless-frame-navigator ${this.dense ? 'dense' : ''}">
${this.dense
? nothing
: html`<span
style="color: ${cssVar('textPrimaryColor')}"
class="edgeless-frame-navigator-title"
@click=${() => this._moveToCurrentFrame()}
>
${frame?.title ?? 'no frame'}
</span>`}
<span class="edgeless-frame-navigator-count">
${frames.length === 0 ? 0 : current + 1} / ${frames.length}
</span>
</div>
<edgeless-tool-icon-button
.tooltip=${'Next'}
@click=${() => this._nextFrame()}
.iconContainerPadding=${0}
.iconSize=${'24px'}
>
${EndPointArrowIcon()}
</edgeless-tool-icon-button>
<div class="full-divider"></div>
<div class="config-buttons">
<edgeless-tool-icon-button
.tooltip=${document.fullscreenElement
? 'Exit Full Screen'
: 'Enter Full Screen'}
@click=${() => this._toggleFullScreen()}
.iconContainerPadding=${0}
.iconContainerWidth=${'24px'}
.iconSize=${'24px'}
>
${document.fullscreenElement ? ExpandCloseIcon() : ExpandFullIcon()}
</edgeless-tool-icon-button>
${this.dense
? nothing
: html`<edgeless-frame-order-button
.popperShow=${this.frameMenuShow}
.setPopperShow=${this.setFrameMenuShow}
.edgeless=${this.edgeless}
>
</edgeless-frame-order-button>`}
<edgeless-navigator-setting-button
.edgeless=${this.edgeless}
.hideToolbar=${this._cachedPresentHideToolbar}
.onHideToolbarChange=${(hideToolbar: boolean) => {
this._cachedPresentHideToolbar = hideToolbar;
}}
.popperShow=${this.settingMenuShow}
.setPopperShow=${this.setSettingMenuShow}
.includeFrameOrder=${this.dense}
>
</edgeless-navigator-setting-button>
</div>
<div class="full-divider"></div>
<button
class="edgeless-frame-navigator-stop"
@click=${this._exitPresentation}
style="background: ${cssVar('warningColor')}"
>
${StopAiIcon()}
</button>
`;
}
protected override updated(changedProperties: PropertyValues) {
if (
changedProperties.has('_currentFrameIndex') &&
this.edgelessTool.type === 'frameNavigator'
) {
this._moveToCurrentFrame();
}
}
@state({
hasChanged() {
return true;
},
})
private accessor _currentFrameIndex = 0;
private accessor _fullScreenMode = true;
@state()
private accessor _navigatorMode: NavigatorMode = 'fit';
@property({ attribute: false })
accessor containerWidth = 1920;
@property({ type: Boolean })
accessor frameMenuShow = false;
@property()
accessor setFrameMenuShow: (show: boolean) => void = () => {};
@property()
accessor setSettingMenuShow: (show: boolean) => void = () => {};
@property({ type: Boolean })
accessor settingMenuShow = false;
}
declare global {
interface HTMLElementTagNameMap {
'presentation-toolbar': PresentationToolbar;
}
}
@@ -0,0 +1,255 @@
import { html } from 'lit';
export const ScribbledSquareIcon = html`<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.45993 3.83639C2.64271 3.81446 2.80866 3.94487 2.83059 4.12765C2.92285 4.89681 3.01537 5.61006 3.10321 6.28727C3.55933 9.80379 3.88941 12.3486 3.40424 16.7043C3.38386 16.8872 3.21901 17.019 3.03605 16.9986C2.85309 16.9783 2.72129 16.8134 2.74167 16.6304C3.21749 12.3587 2.89776 9.89136 2.44329 6.38415C2.3552 5.70441 2.26206 4.98561 2.16867 4.20705C2.14674 4.02427 2.27714 3.85832 2.45993 3.83639Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.6788 16.6731C13.5378 16.6615 12.1261 16.6616 11.0279 16.7078C9.45128 16.7741 7.43455 16.9812 5.80651 17.1726C4.99366 17.2682 4.27987 17.3596 3.76935 17.4271C3.51411 17.4609 3.30974 17.4887 3.16929 17.508L3.00817 17.5303L2.95355 17.538L2.90588 17.2081L2.85912 16.878L2.91585 16.8701L3.07845 16.8475C3.21994 16.8281 3.42545 16.8001 3.68194 16.7662C4.19488 16.6984 4.91194 16.6066 5.72866 16.5105C7.35976 16.3187 9.39704 16.1091 10.9999 16.0417C12.1163 15.9947 13.5424 15.9948 14.6856 16.0065C15.258 16.0123 15.761 16.0211 16.1209 16.0284C16.3009 16.032 16.4451 16.0353 16.5445 16.0377L16.6586 16.0405L16.6978 16.0415L16.6895 16.3747L16.6805 16.708L16.6416 16.7069L16.5285 16.7042C16.4299 16.7018 16.2865 16.6985 16.1074 16.6949C15.7492 16.6876 15.2485 16.6789 14.6788 16.6731ZM17.0228 16.3837C17.0178 16.5677 16.8646 16.7129 16.6805 16.708L16.6895 16.3747L16.6978 16.0415C16.8819 16.0465 17.0277 16.1997 17.0228 16.3837ZM2.57586 17.2549C2.54999 17.0727 2.67685 16.9039 2.85912 16.878L2.90588 17.2081L2.95355 17.538C2.77128 17.5639 2.60173 17.4372 2.57586 17.2549Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16.2785 3.00004C16.4626 2.99705 16.6142 3.14384 16.6172 3.32791L16.2839 3.33333L15.9506 3.33875C15.9476 3.15468 16.0944 3.00304 16.2785 3.00004ZM15.8785 17.5L15.5461 17.4759L15.5604 17.2746C15.5695 17.1442 15.5827 16.9546 15.5988 16.7179C15.631 16.2445 15.6746 15.5827 15.7206 14.8294C15.8126 13.3217 15.9136 11.4516 15.9507 9.99154C15.9836 8.69575 15.9836 7.03343 15.9754 5.69274C15.9712 5.02289 15.9651 4.43425 15.9599 4.01309C15.9573 3.80252 15.955 3.63385 15.9533 3.51786L15.9506 3.33875L16.2839 3.33333L16.6172 3.32791L16.6199 3.50823C16.6216 3.62466 16.6239 3.79382 16.6265 4.00493C16.6317 4.42711 16.6379 5.01714 16.642 5.68864C16.6503 7.03063 16.6503 8.70165 16.6171 10.0085C16.5797 11.4809 16.4781 13.3608 16.386 14.87C16.3399 15.6251 16.2962 16.2885 16.2639 16.7631C16.2478 17.0004 16.2346 17.1905 16.2254 17.3214L16.211 17.5232L15.8785 17.5ZM15.8785 17.5L15.5461 17.4759C15.5327 17.6595 15.6708 17.8192 15.8544 17.8325C16.038 17.8458 16.1977 17.7077 16.211 17.5241L15.8785 17.5Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.8222 4.25378L17.8204 4.26045L17.8149 4.2812L17.7936 4.36248C17.7751 4.43352 17.7485 4.53713 17.7161 4.66674C17.6512 4.92604 17.5634 5.28896 17.4713 5.70299C17.2862 6.53525 17.0878 7.55848 17.0216 8.36138C16.9302 9.47192 16.9847 10.3393 17.0561 11.223C17.0633 11.3116 17.0706 11.4004 17.078 11.4898C17.1443 12.2918 17.2137 13.1322 17.1847 14.1766C17.1687 14.7528 17.1279 15.4868 17.0913 16.0741C17.073 16.3683 17.0556 16.6267 17.0429 16.8116C17.0365 16.9041 17.0312 16.9782 17.0276 17.0292L17.0219 17.1081L16.6894 17.084L16.357 17.0596L16.3626 16.9817C16.3662 16.9312 16.3714 16.8576 16.3778 16.7657C16.3905 16.5819 16.4077 16.325 16.4259 16.0326C16.4624 15.4467 16.5027 14.7223 16.5183 14.1581C16.5462 13.1522 16.4798 12.3467 16.4135 11.5437C16.4062 11.4547 16.3988 11.3658 16.3916 11.2767C16.3193 10.3821 16.2613 9.47185 16.3572 8.30666C16.4273 7.45607 16.6342 6.39597 16.8205 5.55827C16.9141 5.13732 17.0034 4.7686 17.0693 4.50502C17.1023 4.37318 17.1294 4.26753 17.1484 4.19467L17.1704 4.11077L17.1762 4.08879L17.1781 4.08154C17.2259 3.90374 17.4089 3.79769 17.5867 3.84542C17.7645 3.89315 17.8699 4.07598 17.8222 4.25378ZM16.6894 17.084L16.357 17.0596C16.3435 17.2432 16.4814 17.403 16.665 17.4165C16.8486 17.4299 17.0084 17.2917 17.0219 17.1081L16.6894 17.084Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.17283 3.02528C2.20726 2.84434 2.38177 2.72526 2.5626 2.75931L2.50025 3.08692L2.43789 3.41454C2.25707 3.38049 2.13839 3.20622 2.17283 3.02528ZM16.7595 4.58093L16.7545 4.91426L16.7174 4.91377L16.6111 4.91221C16.5186 4.9108 16.3842 4.9086 16.2166 4.90547C15.8815 4.89921 15.4131 4.88922 14.8802 4.87428C13.8159 4.84446 12.4887 4.79477 11.451 4.71525C9.67135 4.57888 7.41581 4.25301 5.61119 3.96257C4.70758 3.81715 3.91464 3.68021 3.34754 3.57961C3.06396 3.52931 2.83678 3.48809 2.68039 3.45942C2.60219 3.44508 2.54168 3.43388 2.50067 3.42626L2.43789 3.41454C2.43784 3.41453 2.43789 3.41454 2.50025 3.08692L2.5626 2.75931L2.62388 2.77075C2.66438 2.77828 2.72434 2.78937 2.80196 2.80361C2.95722 2.83207 3.18314 2.87307 3.46534 2.92312C4.0298 3.02325 4.81916 3.15957 5.7185 3.30431C7.51977 3.59421 9.75269 3.91632 11.5034 4.05047C12.5242 4.1287 13.8381 4.17805 14.9003 4.20782C15.4307 4.22268 15.8969 4.23263 16.2305 4.23886C16.3973 4.24197 16.5309 4.24416 16.6228 4.24556L16.7281 4.24711L16.7638 4.24758L16.7595 4.58093ZM16.7595 4.58093L16.7545 4.91426C16.9386 4.91657 17.09 4.76919 17.0927 4.5851C17.0954 4.401 16.9479 4.24989 16.7638 4.24758L16.7595 4.58093Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.28443 2.99528C6.67178 2.78395 8.41647 2.57209 9.79924 2.58239C11.1018 2.59209 12.7438 2.80384 14.0493 3.01027C14.7044 3.11385 15.2791 3.21681 15.6901 3.29389C15.8957 3.33244 16.0604 3.36454 16.174 3.38705C16.2308 3.3983 16.2747 3.40715 16.3046 3.41322L16.3387 3.42017L16.3499 3.42246C16.3499 3.42247 16.3508 3.42264 16.2832 3.74905L16.2156 4.07546L16.2049 4.07326L16.1721 4.06658C16.1432 4.06071 16.1001 4.05205 16.0444 4.041C15.9328 4.01889 15.7703 3.98723 15.5672 3.94914C15.1609 3.87294 14.5926 3.77113 13.9452 3.66876C12.6456 3.46327 11.0445 3.25835 9.79428 3.24904C8.46713 3.23916 6.7659 3.44397 5.38482 3.65435C4.69677 3.75916 4.09275 3.86462 3.66085 3.94386C3.44495 3.98348 3.2722 4.01651 3.15363 4.0396C3.09434 4.05115 3.04861 4.0602 3.01781 4.06635L2.98298 4.07334L2.97218 4.07553L2.90493 3.74905C2.83827 3.42245 2.83831 3.42244 2.83836 3.42243L2.85104 3.41986L2.8873 3.41259C2.91908 3.40624 2.96584 3.39698 3.0262 3.38523C3.1469 3.36172 3.32204 3.32823 3.54054 3.28814C3.97744 3.20798 4.58826 3.10133 5.28443 2.99528ZM2.90493 3.74905L2.83836 3.42243C2.65798 3.45925 2.54151 3.63533 2.57833 3.81571C2.61514 3.99608 2.7918 4.11234 2.97218 4.07553L2.90493 3.74905ZM16.6096 3.81662C16.5723 3.99689 16.3959 4.11278 16.2156 4.07546L16.2832 3.74905L16.3508 3.42264C16.531 3.45996 16.6469 3.63635 16.6096 3.81662Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.28464 4.25107C3.46815 4.23644 3.62878 4.37334 3.64341 4.55685L3.31113 4.58335L2.97886 4.60985C2.96422 4.42634 3.10112 4.26571 3.28464 4.25107ZM4.12194 17.0834L3.78903 17.1002L3.77925 16.9095C3.77288 16.7861 3.76358 16.6067 3.75184 16.3828C3.72836 15.9349 3.69511 15.3089 3.65607 14.5966C3.57795 13.1716 3.47669 11.4024 3.38395 10.0224C3.31295 8.96574 3.2117 7.61287 3.12814 6.52347C3.08637 5.97892 3.04905 5.50047 3.02217 5.15816L2.97886 4.60985L3.31113 4.58335L3.64341 4.55685L3.68679 5.10597C3.71369 5.44858 3.75105 5.92744 3.79285 6.47249C3.87644 7.56228 3.97789 8.91775 4.04912 9.97767C4.14215 11.362 4.24359 13.1346 4.32174 14.5601C4.36082 15.2731 4.39409 15.8996 4.41759 16.3479C4.42935 16.572 4.43866 16.7516 4.44503 16.8752L4.4548 17.0656L4.12194 17.0834ZM4.12194 17.0834L3.78903 17.1002C3.79838 17.2841 3.95501 17.4256 4.13887 17.4163C4.32272 17.4069 4.46415 17.2495 4.4548 17.0656L4.12194 17.0834Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.60257 16.7254C6.90834 16.7169 8.53454 16.7169 9.80648 16.751C11.2396 16.7895 13.0691 16.894 14.5375 16.9886C15.2723 17.036 15.9178 17.0809 16.3797 17.1141C16.6106 17.1306 16.7956 17.1442 16.923 17.1537L17.1194 17.1685L17.0948 17.5009L17.0693 17.8333L16.8735 17.8185C16.7467 17.8091 16.5623 17.7956 16.332 17.779C15.8714 17.746 15.2275 17.7011 14.4947 17.6539C13.0278 17.5594 11.2086 17.4555 9.78861 17.4174C8.52831 17.3836 6.91129 17.3836 5.60691 17.3921C4.95524 17.3963 4.38257 17.4027 3.97284 17.408C3.76798 17.4106 3.60389 17.413 3.49105 17.4147L3.31753 17.4175L3.31112 17.0842L3.30545 16.7509L3.48089 16.7481C3.59419 16.7464 3.7588 16.744 3.96421 16.7414C4.37503 16.7361 4.94916 16.7297 5.60257 16.7254ZM3.31112 17.0842L3.30545 16.7509C3.12138 16.7541 2.97467 16.9059 2.97783 17.09C2.981 17.274 3.13346 17.4207 3.31753 17.4175L3.31112 17.0842ZM17.4271 17.5263C17.4131 17.7099 17.2529 17.8473 17.0693 17.8333L17.0948 17.5009L17.1202 17.1685C17.3038 17.1826 17.4412 17.3428 17.4271 17.5263Z"
/>
</svg>`;
export const ScribbledEllipseIcon = html`<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.55739 3.93568C7.46902 0.8947 12.2002 1.1564 15.2608 3.91876C16.5142 5.04996 17.2816 6.49066 17.5878 8.01768C18.0083 8.94994 18.234 9.92054 18.249 10.8891C18.2779 12.7636 17.5162 14.6002 15.9043 16.0977C14.392 17.5028 12.5814 18.2375 10.7098 18.2491C9.97219 18.2537 9.22899 18.146 8.49453 17.9255C6.64295 17.767 4.87334 16.9658 3.58343 15.4367C1.676 13.1755 1.2832 10.344 2.28148 7.79433C2.57576 6.38916 3.46976 5.07164 4.55739 3.93568ZM2.42485 10.2275C2.34071 11.9006 2.88716 13.5774 4.093 15.0069C4.3286 15.2861 4.5828 15.5389 4.85254 15.7656C3.68056 14.5772 3.28103 13.0713 3.22018 11.7016C3.04507 11.4605 2.8826 11.214 2.75731 10.9819C2.62124 10.7297 2.51108 10.4781 2.42485 10.2275ZM3.2485 10.4782C2.84526 9.64052 2.76482 8.80019 2.9267 7.97457C3.20512 7.28242 3.59656 6.61183 4.09876 5.98247C6.83382 2.55491 12.419 2.76331 15.4335 6.00168C16.083 6.69948 16.589 7.44767 16.9464 8.21807C17.3965 10.5743 16.656 13.1556 14.8033 14.9846C13.1298 16.6366 10.8047 17.4377 8.61442 17.265C7.6035 16.9502 6.60135 16.3988 5.65306 15.5988C4.68607 14.7829 4.20276 13.7377 4.0001 12.6734C4.13559 12.8295 4.25734 12.9645 4.35215 13.0679C4.41501 13.1364 4.46616 13.1911 4.50176 13.2288L4.54306 13.2724L4.5541 13.2839L4.55706 13.287L4.55788 13.2879C4.6857 13.4204 4.8971 13.4246 5.02959 13.2967C5.16207 13.1689 5.16587 12.9579 5.03807 12.8254C5.03807 12.8254 5.03808 12.8254 5.03807 12.8254L5.03746 12.8248L5.03511 12.8224L5.02535 12.8121L4.98676 12.7714C4.95304 12.7357 4.90399 12.6832 4.84339 12.6172C4.7221 12.485 4.55505 12.2989 4.3723 12.0836C4.21091 11.8934 4.04049 11.6842 3.87965 11.4717C3.87539 11.2461 3.88058 11.0233 3.89318 10.8054C3.9396 10.0027 4.08633 9.28214 4.22199 8.76145C4.2897 8.50157 4.35434 8.29272 4.40167 8.14979C4.42533 8.07836 4.44464 8.02349 4.45781 7.98704C4.4644 7.96882 4.46945 7.95521 4.47274 7.94645L4.47632 7.93698L4.47707 7.93503C4.47705 7.93508 4.47709 7.93498 4.47707 7.93503C4.47705 7.9351 4.4771 7.93496 4.16643 7.81414M12.2502 17.3881C13.3911 17.104 14.4816 16.5096 15.4506 15.6093C16.8479 14.3111 17.5284 12.7623 17.5799 11.1863C17.2636 12.7577 16.4855 14.2608 15.2717 15.459C14.4028 16.3168 13.3632 16.9667 12.2502 17.3881ZM15.4936 5.12111C12.8787 2.70257 8.80143 2.11296 5.76705 3.74252C8.4055 1.71255 12.2223 2.07439 14.8142 4.41366C15.0612 4.63662 15.2876 4.87313 15.4936 5.12111Z"
/>
</svg>`;
export const ScribbledDiamondIcon = html`<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.1077 1.89441C11.212 2.04607 11.1737 2.25361 11.022 2.35797L11.0139 2.36353L10.9891 2.38073C10.9672 2.39597 10.9346 2.41866 10.8925 2.44821C10.8083 2.50731 10.686 2.59381 10.5341 2.70295C10.2302 2.9213 9.80858 3.22993 9.33796 3.59077C8.39262 4.31558 7.26483 5.23952 6.49268 6.06158C5.75373 6.84829 4.9324 7.97618 4.29023 8.91779C3.97053 9.38655 3.69777 9.80539 3.50499 10.1069C3.40863 10.2577 3.33233 10.379 3.28022 10.4625C3.25417 10.5042 3.23417 10.5364 3.22074 10.5582L3.20559 10.5827L3.20093 10.5903C3.10482 10.7473 2.89938 10.7971 2.74236 10.701C2.58535 10.6049 2.53598 10.3997 2.63209 10.2427L2.63771 10.2335L2.65361 10.2077C2.66756 10.1852 2.6881 10.152 2.71472 10.1094C2.76796 10.0241 2.84553 9.90079 2.94329 9.74786C3.13876 9.4421 3.41522 9.01758 3.73945 8.54216C4.38514 7.5954 5.23047 6.43162 6.00676 5.60515C6.81794 4.74154 7.98182 3.79048 8.93232 3.06171C9.40961 2.69576 9.83695 2.38295 10.1451 2.16156C10.2992 2.05083 10.4235 1.96289 10.5096 1.90251C10.5526 1.87232 10.586 1.84901 10.6088 1.83319L10.6349 1.81514L10.6434 1.80924C10.6434 1.80921 10.6441 1.80876 10.8331 2.08336L10.6441 1.80876C10.7958 1.7044 11.0033 1.74275 11.1077 1.89441Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1.82582 10.6214C1.94287 10.4793 2.15295 10.459 2.29504 10.5761L2.0831 10.8333L1.87116 11.0906C1.72907 10.9736 1.70877 10.7635 1.82582 10.6214ZM9.16643 17.9167L8.90914 18.1286L8.88737 18.1022L8.82364 18.0254C8.768 17.9584 8.68686 17.8611 8.58513 17.7399C8.38164 17.4975 8.09589 17.1598 7.76706 16.7791C7.1079 16.0159 6.28112 15.0861 5.5974 14.4024C4.91367 13.7187 3.98383 12.8919 3.22065 12.2327C2.83993 11.9039 2.5023 11.6181 2.25988 11.4146C2.1387 11.3129 2.04135 11.2318 1.97438 11.1761L1.89753 11.1124L1.87116 11.0906L2.0831 10.8333L2.29504 10.5761L2.32245 10.5987L2.40042 10.6634C2.46817 10.7197 2.56638 10.8015 2.68852 10.904C2.93274 11.1091 3.27283 11.3969 3.65642 11.7282C4.42187 12.3893 5.36702 13.2292 6.0688 13.931C6.77058 14.6328 7.61046 15.5779 8.27159 16.3434C8.60291 16.7269 8.89072 17.067 9.09574 17.3113C9.19826 17.4334 9.28012 17.5316 9.33641 17.5993L9.40107 17.6773L9.4233 17.7042L9.16643 17.9167ZM9.16643 17.9167L8.90914 18.1286C9.0262 18.2707 9.23628 18.291 9.37837 18.174C9.52046 18.0569 9.54035 17.8463 9.4233 17.7042L9.16643 17.9167Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.6976 10.1657C17.8365 10.045 18.0471 10.0597 18.1678 10.1987C18.2886 10.3376 18.2739 10.5481 18.1349 10.6689L17.9162 10.4173L17.6976 10.1657ZM9.99958 17.9173L9.76008 17.6855L9.86002 17.5825C9.9246 17.516 10.0185 17.4195 10.1358 17.2992C10.3704 17.0587 10.6989 16.7232 11.0744 16.343C11.8247 15.5834 12.7652 14.6428 13.52 13.9257C14.3177 13.1677 15.3629 12.2271 16.2067 11.4776C16.629 11.1026 17.0016 10.7747 17.2686 10.5406C17.4022 10.4235 17.5093 10.3299 17.5831 10.2654L17.6976 10.1657L17.9162 10.4173L18.1349 10.6689L18.0215 10.7678C17.948 10.8319 17.8412 10.9252 17.7081 11.0419C17.4419 11.2753 17.0704 11.6022 16.6494 11.9761C15.8066 12.7246 14.7684 13.6591 13.9792 14.409C13.2325 15.1184 12.2981 16.0528 11.5487 16.8115C11.1744 17.1905 10.8469 17.525 10.613 17.7648C10.496 17.8846 10.4025 17.9808 10.3382 18.047L10.2395 18.1487L9.99958 17.9173ZM9.99958 17.9173L9.76008 17.6855C9.63205 17.8178 9.63548 18.0288 9.76777 18.1569C9.90005 18.2849 10.1115 18.281 10.2395 18.1487L9.99958 17.9173Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.86605 1.93879C8.94592 1.77293 9.14513 1.70321 9.31099 1.78308L9.16638 2.08341C9.31099 1.78308 9.31095 1.78306 9.31099 1.78308L9.31301 1.78405L9.31817 1.78655L9.33763 1.79603C9.35457 1.80431 9.37932 1.81647 9.41107 1.83223C9.47457 1.86376 9.56614 1.90976 9.67943 1.9681C9.90588 2.08472 10.2198 2.251 10.57 2.44996C11.2644 2.84444 12.1242 3.37987 12.7228 3.91908C13.7803 4.87171 14.411 5.77009 15.1185 6.77795C15.4184 7.20516 15.7321 7.65205 16.0979 8.13108C16.36 8.47421 16.6824 9.01299 16.9328 9.45249C17.0596 9.67498 17.1707 9.87686 17.2501 10.0231C17.2898 10.0963 17.3217 10.1556 17.3437 10.1968L17.369 10.2444L17.3756 10.257L17.3778 10.2611C17.4636 10.4241 17.4012 10.626 17.2383 10.7117C17.0754 10.7975 16.8738 10.7349 16.7881 10.572C16.7881 10.572 16.7881 10.572 16.7881 10.572L16.7861 10.5682L16.7799 10.5565L16.7555 10.5107C16.7342 10.4708 16.7031 10.4128 16.6642 10.3412C16.5864 10.1978 16.4776 10.0001 16.3536 9.78259C16.1026 9.34207 15.7999 8.83917 15.5681 8.53574C15.1665 8.00993 14.837 7.54007 14.5307 7.10341C13.845 6.12584 13.2759 5.31463 12.2766 4.41441C11.7346 3.92618 10.9278 3.41994 10.2407 3.02962C9.90015 2.83617 9.59455 2.67427 9.37421 2.56079C9.2641 2.50409 9.17545 2.45957 9.11458 2.42934C9.08414 2.41423 9.06066 2.4027 9.04493 2.39501L9.02723 2.38639L9.02293 2.3843L9.02195 2.38383C9.02194 2.38383 9.02196 2.38383 9.02195 2.38383C8.8561 2.30395 8.78618 2.10465 8.86605 1.93879Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.1351 1.77876C10.3033 1.85355 10.379 2.05054 10.3042 2.21876L9.99966 2.08335C10.3042 2.21876 10.3043 2.21867 10.3042 2.21876L10.3038 2.21976L10.303 2.22152L10.3004 2.2273L10.291 2.24778C10.2828 2.26534 10.2709 2.29066 10.255 2.32333C10.2233 2.38866 10.1759 2.48339 10.1117 2.60425C9.9834 2.84595 9.78793 3.19226 9.51656 3.61699C8.97388 4.46634 8.12722 5.63001 6.90645 6.89788C5.76129 8.08722 4.70343 8.93382 3.92952 9.48433C3.54251 9.75963 3.22631 9.96102 3.00544 10.0943C2.89499 10.161 2.80836 10.2106 2.7486 10.244C2.71871 10.2607 2.69555 10.2733 2.67948 10.2819L2.66074 10.2919L2.65545 10.2947L2.65384 10.2955L2.65309 10.2959C2.65301 10.296 2.65293 10.296 2.49966 10L2.65309 10.2959C2.48961 10.3806 2.2883 10.3168 2.20366 10.1533C2.11903 9.98984 2.18288 9.78875 2.34627 9.70407C2.34623 9.70409 2.34631 9.70405 2.34627 9.70407L2.34968 9.70228L2.36383 9.69474C2.37682 9.68775 2.39686 9.67685 2.42359 9.66193C2.47703 9.63209 2.55716 9.5862 2.66095 9.52356C2.86854 9.39827 3.17066 9.206 3.54309 8.94108C4.28804 8.41117 5.31352 7.5911 6.42621 6.43548C7.61256 5.20335 8.43257 4.07535 8.95477 3.25804C9.21585 2.84943 9.40237 2.51866 9.52292 2.29161C9.5832 2.1781 9.62696 2.09053 9.6553 2.03217C9.66947 2.00298 9.67978 1.9811 9.68636 1.96693L9.69346 1.9515L9.69495 1.94821C9.76978 1.78008 9.9669 1.70399 10.1351 1.77876Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.20044 11.1035C2.28101 10.938 2.48051 10.8691 2.64604 10.9497L2.65448 10.9538L2.67799 10.9654C2.69853 10.9755 2.72864 10.9904 2.76738 11.0097C2.84485 11.0484 2.95685 11.1047 3.09572 11.176C3.37337 11.3186 3.75901 11.5212 4.19138 11.7614C5.05137 12.2392 6.11398 12.8756 6.87603 13.4899C7.75522 14.1987 8.71011 15.2618 9.43707 16.1347C9.80256 16.5736 10.1142 16.969 10.3345 17.2547C10.4447 17.3977 10.5322 17.5133 10.5923 17.5934C10.6223 17.6335 10.6455 17.6647 10.6613 17.686L10.6793 17.7104L10.684 17.7167L10.6852 17.7184C10.6853 17.7185 10.6857 17.7191 10.4168 17.9161L10.6857 17.7191C10.7945 17.8676 10.7623 18.0762 10.6138 18.185C10.4653 18.2938 10.2567 18.2616 10.1479 18.1131C10.1479 18.1131 10.1479 18.1131 10.1479 18.1131L10.1425 18.1057L10.1256 18.0828C10.1105 18.0625 10.0881 18.0324 10.0589 17.9934C10.0004 17.9154 9.91477 17.8021 9.80657 17.6618C9.59008 17.381 9.28376 16.9924 8.92478 16.5613C8.20281 15.6944 7.2827 14.6741 6.45761 14.0089C5.74236 13.4323 4.72163 12.8186 3.86761 12.3442C3.44298 12.1083 3.06395 11.9092 2.79118 11.7691C2.65485 11.6991 2.54519 11.6439 2.46982 11.6063C2.43213 11.5875 2.40303 11.5731 2.38345 11.5635L2.36135 11.5526L2.3546 11.5493"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.1157 2.32268C11.1157 2.32267 11.1157 2.32268 11.1157 2.32268L11.1168 2.32437L11.1208 2.33079L11.1378 2.35749C11.1531 2.38131 11.1762 2.417 11.2067 2.46344C11.2679 2.55632 11.3589 2.69213 11.4775 2.86197C11.7147 3.20175 12.0615 3.67705 12.4984 4.21674C13.3744 5.29881 14.6027 6.6276 16.0268 7.64481C16.362 7.88419 16.7818 8.19911 17.1162 8.45286C17.2837 8.58001 17.4305 8.69231 17.5354 8.77284L17.6588 8.86774L17.7036 8.90235L17.4997 9.16606L17.2958 9.42971L17.2518 9.39574L17.1294 9.30165C17.0253 9.2217 16.8795 9.11016 16.7132 8.98393C16.3797 8.7309 15.9662 8.42081 15.6393 8.1873C14.1468 7.12119 12.8751 5.74164 11.9802 4.63621C11.5317 4.08215 11.1754 3.5939 10.9308 3.24358C10.8085 3.06837 10.714 2.92749 10.6498 2.82994C10.6177 2.78116 10.5932 2.7432 10.5765 2.71718L10.5575 2.68725L10.5524 2.67926L10.551 2.67705L10.5505 2.67619C10.5504 2.67612 10.5504 2.67606 10.8331 2.49939L10.5505 2.67619C10.4529 2.52008 10.5003 2.31429 10.6564 2.21672C10.8125 2.11917 11.0181 2.16661 11.1157 2.32268ZM17.4997 9.16606L17.2958 9.42971C17.4414 9.54235 17.6508 9.51562 17.7634 9.37001C17.876 9.22439 17.8492 9.01499 17.7036 8.90235L17.4997 9.16606Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.0171 9.57164C17.1434 9.43772 17.3544 9.43155 17.4883 9.55787C17.6222 9.68418 17.6284 9.89515 17.5021 10.0291L17.2596 9.80035L17.0171 9.57164ZM11.0737 17.7016L10.794 17.5203L10.8145 17.4887L10.8734 17.3985C10.9246 17.3201 10.9992 17.2065 11.0926 17.065C11.2796 16.7822 11.5424 16.388 11.8458 15.9426C12.4514 15.0538 13.2238 13.9538 13.8796 13.1278C14.459 12.3981 15.2447 11.5079 15.8823 10.8034C16.2017 10.4504 16.4852 10.1427 16.6889 9.92319C16.7908 9.81342 16.8727 9.72566 16.9292 9.66529L16.9943 9.59593L17.0171 9.57164L17.2596 9.80035L17.5021 10.0291L17.48 10.0525L17.4159 10.1209C17.36 10.1806 17.2787 10.2677 17.1776 10.3766C16.9754 10.5946 16.6938 10.9003 16.3766 11.2508C15.7408 11.9532 14.9671 12.8304 14.4017 13.5424C13.7618 14.3484 13.0007 15.4316 12.3968 16.318C12.0954 16.7603 11.8344 17.1517 11.6488 17.4326C11.556 17.573 11.4821 17.6857 11.4314 17.7633L11.3734 17.8522L11.3538 17.8824L11.0737 17.7016ZM11.0737 17.7016L10.794 17.5203C10.6939 17.6748 10.738 17.8813 10.8925 17.9814C11.047 18.0814 11.2537 18.0369 11.3538 17.8824L11.0737 17.7016Z"
/>
</svg>`;
export const ScribbledTriangleIcon = html`<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.1042 1.55591C10.2801 1.61017 10.3787 1.79676 10.3245 1.97268L10.0059 1.87444L9.6874 1.7762C9.74166 1.60028 9.92825 1.50165 10.1042 1.55591ZM2.92782 15.5656L2.66613 15.3592L2.67298 15.3505L2.69374 15.324C2.71206 15.3007 2.7392 15.2659 2.77423 15.2209C2.84431 15.1308 2.94597 14.9995 3.07196 14.8347C3.32399 14.5053 3.6731 14.0427 4.0612 13.5107C4.83961 12.4437 5.76667 11.1085 6.38444 10.0089C7.22603 8.51089 8.05125 6.4576 8.66899 4.76689C8.97699 3.92394 9.23181 3.17556 9.4096 2.63803C9.49848 2.36931 9.56806 2.15343 9.61536 2.00491C9.63902 1.93066 9.65709 1.87326 9.66922 1.83453L9.68291 1.79067L9.6874 1.7762L10.0059 1.87444C10.3245 1.97268 10.3245 1.97262 10.3245 1.97268L10.3241 1.97381L10.3195 1.98852L10.3054 2.0338C10.293 2.0735 10.2746 2.13192 10.2506 2.20723C10.2026 2.35785 10.1323 2.5761 10.0425 2.84738C9.86313 3.38983 9.60604 4.14488 9.29517 4.99568C8.67521 6.69249 7.835 8.78805 6.96567 10.3354C6.32758 11.4712 5.38103 12.8327 4.59978 13.9036C4.20806 14.4406 3.85583 14.9073 3.60148 15.2398C3.47429 15.4061 3.3715 15.5389 3.30042 15.6303C3.26488 15.6759 3.23727 15.7113 3.21848 15.7352L3.19704 15.7626L3.19006 15.7714L2.92782 15.5656ZM2.92782 15.5656L2.66613 15.3592C2.55211 15.5037 2.57684 15.7133 2.72137 15.8274C2.86591 15.9414 3.07551 15.9166 3.18953 15.7721L2.92782 15.5656Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.15809 16.0728C6.66763 16.0543 8.54993 16.0541 10.0224 16.1288C11.5853 16.2081 13.5722 16.4167 15.1634 16.6046C15.9601 16.6987 16.6596 16.7879 17.1599 16.8535C17.4101 16.8863 17.6106 16.9133 17.7486 16.932L17.9071 16.9537L17.9624 16.9614C18.1448 16.9869 18.272 17.1554 18.2465 17.3377C18.221 17.52 18.0525 17.6471 17.8702 17.6216L17.816 17.6141L17.6588 17.5926C17.5217 17.574 17.3223 17.5472 17.0732 17.5145C16.575 17.4492 15.8785 17.3604 15.0853 17.2667C13.4967 17.0791 11.5282 16.8728 9.98864 16.7947C8.53815 16.7211 6.67322 16.7209 5.16628 16.7394C4.4138 16.7487 3.7524 16.7626 3.27918 16.7741C3.04259 16.7799 2.85308 16.7851 2.72281 16.7889L2.57336 16.7933L2.52264 16.7949L2.51107 16.4618L2.50033 16.1286L2.55289 16.1269L2.70361 16.1225C2.83476 16.1187 3.02524 16.1135 3.26289 16.1077C3.73816 16.096 4.40233 16.0821 5.15809 16.0728ZM2.17791 16.4726C2.17194 16.2886 2.31633 16.1346 2.50033 16.1286L2.51107 16.4618L2.52264 16.7949C2.33865 16.8009 2.18388 16.6566 2.17791 16.4726Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.94721 2.04509C9.08267 1.92043 9.29354 1.92918 9.41821 2.06464L9.17293 2.29037L8.92766 2.51609C8.803 2.38063 8.81175 2.16976 8.94721 2.04509ZM17.5001 16.3965L17.1832 16.4996L17.1792 16.4874L17.1669 16.4503C17.1561 16.4175 17.14 16.3688 17.1189 16.3058C17.0767 16.1799 17.0146 15.9968 16.9356 15.7688C16.7775 15.3128 16.5516 14.6777 16.2801 13.9621C15.7356 12.5263 15.0142 10.7827 14.2952 9.50708C13.4583 8.02226 12.1209 6.27766 10.9829 4.89499C10.4157 4.20581 9.90135 3.61049 9.52889 3.18756C9.34269 2.97614 9.19206 2.80792 9.08812 2.69271C9.03616 2.63511 8.99587 2.59077 8.96866 2.56093L8.93783 2.52718L8.92766 2.51609L9.17293 2.29037C9.41821 2.06464 9.41816 2.06459 9.41821 2.06464L9.42942 2.07686L9.46134 2.1118C9.48929 2.14246 9.53035 2.18765 9.58312 2.24614C9.68866 2.36312 9.84107 2.53334 10.0292 2.74696C10.4054 3.17409 10.9247 3.77517 11.4976 4.47135C12.64 5.85938 14.0089 7.64131 14.876 9.17975C15.6209 10.5013 16.3567 12.2842 16.9035 13.7257C17.1777 14.4486 17.4059 15.0899 17.5655 15.5505C17.6454 15.7808 17.7081 15.9661 17.751 16.094C17.7724 16.158 17.7889 16.2076 17.8 16.2413L17.8127 16.2798L17.817 16.2932C17.8171 16.2932 17.8171 16.2933 17.5001 16.3965ZM17.5001 16.3965L17.1832 16.4996C17.2401 16.6747 17.4283 16.7704 17.6033 16.7134C17.7784 16.6564 17.874 16.4682 17.817 16.2932L17.5001 16.3965Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.73652 3.33805C9.84503 3.18934 10.0536 3.15674 10.2023 3.26525C10.351 3.37377 10.3836 3.58229 10.2751 3.731L10.0058 3.53453L9.73652 3.33805ZM3.76041 15.5662L3.44364 15.4624L3.44694 15.4524L3.4565 15.4234L3.4934 15.3123C3.52562 15.2158 3.57267 15.0759 3.63218 14.9017C3.75116 14.5534 3.92007 14.0677 4.11982 13.5182C4.51824 12.422 5.04339 11.0605 5.54203 10.0278C6.21751 8.62874 7.26905 6.95347 8.14522 5.63598C8.5845 4.97543 8.98196 4.40146 9.26965 3.99255C9.41352 3.78805 9.53001 3.62474 9.61064 3.51241C9.65096 3.45624 9.68231 3.41281 9.70364 3.38334L9.72799 3.34977L9.73652 3.33805C9.73655 3.33801 9.73652 3.33805 10.0058 3.53453L10.2751 3.731L10.2673 3.74174L10.2436 3.77428C10.2228 3.80305 10.192 3.84574 10.1522 3.90114C10.0727 4.01194 9.95745 4.17354 9.81489 4.37615C9.52974 4.78146 9.13569 5.35051 8.70034 6.00515C7.82723 7.31804 6.79698 8.96185 6.14238 10.3176C5.65914 11.3185 5.14339 12.6537 4.74638 13.7459C4.54842 14.2905 4.38098 14.772 4.26305 15.1172C4.20409 15.2898 4.15754 15.4282 4.12577 15.5234L4.0895 15.6326L4.08024 15.6606L4.07736 15.6694L3.76041 15.5662ZM3.76041 15.5662L3.44364 15.4624C3.38636 15.6374 3.48174 15.8257 3.6567 15.883C3.83166 15.9403 4.02009 15.8444 4.07736 15.6694L3.76041 15.5662Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.3149 2.38886C10.4893 2.32971 10.6785 2.42308 10.7377 2.59741L10.422 2.70452L10.1064 2.81163C10.0472 2.6373 10.1406 2.44802 10.3149 2.38886ZM10.422 2.70452C10.1064 2.81163 10.1064 2.81156 10.1064 2.81163L10.108 2.8163L10.1124 2.82938L10.1298 2.87998C10.1452 2.92432 10.1678 2.98953 10.1974 3.07354C10.2565 3.24157 10.3432 3.48485 10.4541 3.78682C10.6757 4.39063 10.9939 5.22977 11.3805 6.17156C12.1512 8.04929 13.2023 10.3568 14.3082 12.0168C14.83 12.7999 15.5668 13.7434 16.1684 14.486C16.4702 14.8584 16.7397 15.1824 16.9338 15.4134C17.0308 15.5289 17.1091 15.6212 17.1632 15.6847L17.2255 15.7577L17.2471 15.7829C17.3672 15.9225 17.5781 15.9387 17.7176 15.8186C17.8571 15.6985 17.8729 15.488 17.7528 15.3485L17.7476 15.3425L17.7319 15.3241L17.6708 15.2525C17.6175 15.19 17.5402 15.0988 17.4441 14.9845C17.252 14.7558 16.9851 14.435 16.6864 14.0663C16.0871 13.3267 15.3667 12.4031 14.863 11.6472C13.7957 10.0451 12.765 7.78891 11.9972 5.91841C11.6145 4.98608 11.2994 4.15499 11.0799 3.5571C10.9702 3.25822 10.8845 3.01778 10.8263 2.85229C10.7971 2.76955 10.7749 2.70557 10.76 2.6624L10.7432 2.61351L10.739 2.60126L10.7377 2.59741L10.422 2.70452Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.61027 17.5168C2.43001 17.5541 2.25355 17.4382 2.21619 17.2579C2.17883 17.0776 2.29468 16.9013 2.47494 16.864L2.54259 17.1904C2.47496 16.864 2.47489 16.864 2.47494 16.864L2.49141 16.8606L2.53887 16.8509C2.58054 16.8424 2.64195 16.83 2.7213 16.8142C2.87999 16.7825 3.11044 16.7372 3.3981 16.6823C3.97333 16.5726 4.77773 16.4247 5.69478 16.2721C7.52506 15.9674 9.81818 15.6413 11.634 15.5644C12.7876 15.5156 14.259 15.5457 15.4363 15.5876C16.0262 15.6086 16.5444 15.6325 16.9153 15.6513C17.1008 15.6607 17.2495 15.6688 17.3519 15.6745L17.4696 15.6812L17.5102 15.6836C17.6939 15.6947 17.8347 15.8527 17.8236 16.0365C17.8126 16.2202 17.6547 16.3602 17.4709 16.3491L17.4311 16.3467L17.3149 16.3401C17.2136 16.3344 17.0661 16.3264 16.8819 16.3171C16.5136 16.2985 15.9987 16.2746 15.4129 16.2538C14.2389 16.2121 12.7889 16.1828 11.6625 16.2305C9.88958 16.3055 7.62989 16.6259 5.80453 16.9297C4.89375 17.0813 4.09463 17.2282 3.52328 17.3372C3.23765 17.3916 3.00905 17.4366 2.85202 17.4679C2.77351 17.4836 2.71289 17.4959 2.67198 17.5042L2.62565 17.5136L2.61027 17.5168Z"
/>
</svg>`;
export const ScribbledRoundedRectangleIcon = html`<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.44887 6.33792C2.63082 6.30988 2.80104 6.43465 2.82908 6.61659C2.92357 7.22966 3.01845 7.79891 3.10866 8.34005C3.57794 11.1554 3.92041 13.2099 3.41887 16.7146C3.39279 16.8968 3.22391 17.0234 3.04168 16.9973C2.85944 16.9713 2.73284 16.8024 2.75892 16.6201C3.24508 13.2229 2.91967 11.2677 2.45308 8.4641C2.36247 7.91969 2.26654 7.34329 2.1702 6.71814C2.14216 6.53619 2.26692 6.36596 2.44887 6.33792Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.1061 16.1731C13.9651 16.1615 12.5534 16.1616 11.4552 16.2078C9.87853 16.2741 7.86179 16.4812 6.23376 16.6726C5.42091 16.7682 4.70712 16.8596 4.19659 16.9271C3.94136 16.9609 3.73699 16.9887 3.59653 17.008L3.43542 17.0303L3.38079 17.038L3.33313 16.7081L3.28636 16.378L3.34309 16.3701L3.50569 16.3475C3.64718 16.3281 3.8527 16.3001 4.10919 16.2662C4.62212 16.1984 5.33918 16.1066 6.1559 16.0105C7.787 15.8187 9.82429 15.6091 11.4272 15.5417C12.5436 15.4947 13.9697 15.4948 15.1129 15.5065C15.6853 15.5123 16.1882 15.5211 16.5482 15.5284C16.7281 15.532 16.8724 15.5353 16.9717 15.5377L17.0859 15.5405L17.1251 15.5415L17.1168 15.8747L17.1078 16.208L17.0689 16.2069L16.9558 16.2042C16.8572 16.2018 16.7137 16.1985 16.5346 16.1949C16.1764 16.1876 15.6757 16.1789 15.1061 16.1731ZM17.45 15.8837C17.445 16.0677 17.2918 16.2129 17.1078 16.208L17.1168 15.8747L17.1251 15.5415C17.3091 15.5465 17.455 15.6997 17.45 15.8837ZM3.0031 16.7549C2.97723 16.5727 3.10409 16.4039 3.28636 16.378L3.33313 16.7081L3.38079 17.038C3.19852 17.0639 3.02898 16.9372 3.0031 16.7549Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.4298 5.50028C17.6138 5.49278 17.769 5.63582 17.7765 5.81976L17.4434 5.83334L17.1104 5.84691C17.1029 5.66297 17.2459 5.50778 17.4298 5.50028ZM16.6664 16.6667L16.3384 16.6071L16.3454 16.5681L16.3657 16.4543C16.3832 16.355 16.4084 16.2105 16.4391 16.03C16.5006 15.6689 16.584 15.1641 16.6719 14.5896C16.848 13.4377 17.0404 12.016 17.1108 10.9102C17.1732 9.92887 17.1734 8.66525 17.1576 7.64117C17.1498 7.13036 17.1379 6.68143 17.1281 6.36026C17.1231 6.1997 17.1187 6.07114 17.1155 5.98282L17.1117 5.88158L17.1104 5.84691L17.4434 5.83334L17.7765 5.81976L17.7779 5.85588L17.7817 5.95871C17.785 6.04812 17.7894 6.17791 17.7944 6.3398C17.8044 6.66354 17.8163 7.11595 17.8242 7.63088C17.8401 8.65825 17.8403 9.94366 17.7761 10.9526C17.7037 12.0892 17.5076 13.5351 17.3309 14.6903C17.2423 15.2693 17.1583 15.7779 17.0963 16.1418C17.0654 16.3238 17.0399 16.4696 17.0222 16.5701L17.0017 16.6857L16.9945 16.7255L16.6664 16.6667ZM16.6664 16.6667L16.3384 16.6071C16.3055 16.7882 16.4257 16.9617 16.6068 16.9946C16.788 17.0275 16.9616 16.9066 16.9945 16.7255L16.6664 16.6667Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M18.6816 4.25378C18.6816 4.25378 18.6816 4.25377 18.6816 4.25378L18.6798 4.26045L18.6743 4.2812L18.653 4.36248C18.6345 4.43352 18.6079 4.53713 18.5755 4.66674C18.5106 4.92604 18.4227 5.28896 18.3307 5.70299C18.1456 6.53525 17.9471 7.55848 17.881 8.36138C17.7896 9.47192 17.8441 10.3393 17.9155 11.223C17.9227 11.3116 17.93 11.4004 17.9374 11.4898C18.0036 12.2918 18.0731 13.1322 18.0441 14.1766C18.0281 14.7528 17.9873 15.4868 17.9507 16.0741C17.9323 16.3683 17.915 16.6267 17.9022 16.8116C17.8958 16.9041 17.8906 16.9782 17.887 17.0292L17.8813 17.1081L17.5488 17.084L17.2164 17.0596L17.222 16.9817C17.2256 16.9312 17.2308 16.8576 17.2371 16.7657C17.2498 16.5819 17.2671 16.325 17.2853 16.0326C17.3218 15.4467 17.362 14.7223 17.3777 14.1581C17.4056 13.1522 17.3391 12.3467 17.2729 11.5437C17.2655 11.4547 17.2582 11.3658 17.251 11.2767C17.1787 10.3821 17.1206 9.47185 17.2166 8.30666C17.2867 7.45607 17.4936 6.39597 17.6799 5.55827C17.7735 5.13732 17.8628 4.7686 17.9287 4.50502C17.9617 4.37318 17.9888 4.26753 18.0078 4.19467L18.0298 4.11077L18.0356 4.08879L18.0375 4.08154C18.0852 3.90374 18.2682 3.79769 18.446 3.84542C18.6238 3.89315 18.7293 4.07598 18.6816 4.25378ZM17.5488 17.084L17.2164 17.0596C17.2029 17.2432 17.3408 17.403 17.5244 17.4165C17.708 17.4299 17.8678 17.2917 17.8813 17.1081L17.5488 17.084Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.83426 4.14247C3.84818 3.95882 4.00824 3.82092 4.19176 3.83446L4.16655 4.16698L4.14133 4.49951C3.95782 4.48597 3.82033 4.32611 3.83426 4.14247ZM4.16655 4.16698L4.14133 4.49951L4.18765 4.50286L4.32 4.5122C4.43514 4.5202 4.60234 4.53155 4.81097 4.54497C5.22818 4.5718 5.81134 4.60694 6.47532 4.64014C7.80099 4.70644 9.45741 4.76549 10.7588 4.73432C11.5177 4.71615 12.4849 4.65942 13.2592 4.60755C13.647 4.58156 13.9877 4.55671 14.2315 4.53835C14.3534 4.52917 14.4511 4.52161 14.5184 4.51634L14.6223 4.50811C14.8058 4.49328 14.9433 4.33243 14.9289 4.14888C14.9144 3.96534 14.754 3.82856 14.5705 3.84339L14.4678 3.85152C14.4011 3.85675 14.304 3.86425 14.1829 3.87338C13.9405 3.89163 13.6017 3.91635 13.2161 3.94218C12.4435 3.99394 11.4882 4.04985 10.7443 4.06766C9.46869 4.09821 7.8326 4.04026 6.51001 3.97412C5.84987 3.94111 5.26996 3.90617 4.85514 3.87949C4.64776 3.86615 4.48171 3.85488 4.36759 3.84695L4.23672 3.83772L4.19176 3.83446L4.16655 4.16698Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.90557 4.249C8.03011 4.03755 9.45202 3.82395 10.5835 3.83435C11.6494 3.84415 12.9876 4.05759 14.0458 4.26413C14.5778 4.36796 15.0444 4.47116 15.3782 4.54842C15.5451 4.58707 15.679 4.61926 15.7713 4.64185C15.8175 4.65315 15.8533 4.66204 15.8777 4.66815L15.9056 4.67516L15.9148 4.6775C15.9148 4.67751 15.9155 4.67769 15.833 5.00065L15.9148 4.6775C16.0931 4.72308 16.2015 4.90481 16.156 5.08318C16.1104 5.26154 15.9288 5.36918 15.7505 5.32361M5.0811 5.32388L5.08316 5.32337L5.09006 5.32164L5.11799 5.31473C5.14274 5.30863 5.17957 5.29962 5.22737 5.28813C5.32297 5.26515 5.46239 5.23224 5.63671 5.19276C5.98546 5.11377 6.47323 5.00865 7.02876 4.90419C8.14602 4.6941 9.51451 4.49122 10.5774 4.50099C11.5787 4.51019 12.8668 4.71325 13.9181 4.91845C14.4408 5.02048 14.8997 5.12196 15.2278 5.19791C15.3918 5.23588 15.523 5.26742 15.6129 5.28942C15.6578 5.30042 15.6925 5.30903 15.7158 5.31486L15.742 5.32147L15.7505 5.32361M4.99967 5.00065C4.91823 4.67742 4.91827 4.67741 4.91832 4.67739L4.9211 4.6767L4.92884 4.67476L4.95848 4.66742C4.9844 4.66103 5.02247 4.65173 5.07155 4.63993C5.16971 4.61633 5.312 4.58275 5.48945 4.54256C5.84422 4.46221 6.3402 4.35531 6.90557 4.249M4.99967 5.00065L4.91832 4.67739C4.73981 4.72237 4.63146 4.90357 4.67643 5.08208C4.72141 5.2606 4.90259 5.36885 5.0811 5.32388"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.09851 7.16634C2.28243 7.17439 2.425 7.33002 2.41694 7.51394L2.08393 7.49935L1.75092 7.48477C1.75897 7.30085 1.91459 7.15829 2.09851 7.16634ZM2.91732 16.666L2.59179 16.7377L2.5897 16.7282L2.58376 16.7009L2.56129 16.5965C2.5419 16.5058 2.5141 16.3741 2.48032 16.2097C2.41279 15.8811 2.32128 15.4215 2.22534 14.8975C2.0341 13.8528 1.82281 12.5389 1.75124 11.4944C1.69721 10.7057 1.6975 9.70092 1.71081 8.89957C1.71749 8.49756 1.7275 8.14425 1.73585 7.89139C1.74002 7.76493 1.74377 7.66352 1.74649 7.59361L1.74972 7.51316L1.75092 7.48477L2.08393 7.49935L2.41694 7.51394L2.41581 7.54077L2.41266 7.61951C2.40998 7.68825 2.40627 7.78834 2.40215 7.91337C2.3939 8.16348 2.38399 8.51306 2.37739 8.91065C2.36413 9.7085 2.36435 10.6898 2.41635 11.4488C2.48522 12.454 2.6907 13.7373 2.88111 14.7774C2.976 15.2957 3.06654 15.7505 3.13334 16.0755C3.16674 16.238 3.19418 16.368 3.21324 16.4573L3.23525 16.5595L3.24096 16.5857L3.24274 16.5938L2.91732 16.666ZM2.91732 16.666L2.59179 16.7377C2.63141 16.9175 2.80929 17.0312 2.98907 16.9915C3.16885 16.9519 3.28237 16.7736 3.24274 16.5938L2.91732 16.666Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.62454 15.5047C6.93031 15.4962 8.55651 15.4962 9.82846 15.5303C11.2616 15.5688 13.0911 15.6733 14.5595 15.7679C15.2943 15.8153 15.9398 15.8602 16.4016 15.8934C16.6326 15.9099 16.8176 15.9235 16.9449 15.933L17.1413 15.9478L17.1167 16.2802L17.0913 16.6126L16.8955 16.5978C16.7687 16.5884 16.5842 16.5748 16.3539 16.5583C15.8933 16.5253 15.2495 16.4804 14.5166 16.4332C13.0497 16.3387 11.2306 16.2348 9.81058 16.1967C8.55028 16.1629 6.93327 16.1629 5.62888 16.1714C4.97721 16.1756 4.40454 16.182 3.99481 16.1873C3.78996 16.1899 3.62586 16.1923 3.51303 16.194L3.3395 16.1968L3.33309 15.8635L3.32742 15.5302L3.50286 15.5274C3.61616 15.5257 3.78077 15.5233 3.98619 15.5207C4.397 15.5154 4.97114 15.509 5.62454 15.5047ZM3.33309 15.8635L3.32742 15.5302C3.14335 15.5334 2.99664 15.6852 2.99981 15.8693C3.00297 16.0533 3.15543 16.2 3.3395 16.1968L3.33309 15.8635ZM17.4491 16.3056C17.4351 16.4892 17.2748 16.6266 17.0913 16.6126L17.1167 16.2802L17.1422 15.9478C17.3257 15.9619 17.4632 16.1221 17.4491 16.3056Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.4164 4.16586L15.3112 3.84959C15.1365 3.90772 15.042 4.09645 15.1002 4.27112C15.1583 4.4458 15.347 4.54027 15.5217 4.48214M15.4164 4.16586C15.3112 3.84959 15.3111 3.84961 15.3112 3.84959L15.312 3.8493L15.3136 3.8488L15.3186 3.84714L15.3364 3.84145C15.3515 3.83665 15.3731 3.82992 15.4003 3.82175C15.4547 3.80544 15.5319 3.78331 15.6251 3.75941C15.8101 3.71193 16.0639 3.65588 16.3298 3.62566C16.59 3.59607 16.8897 3.58772 17.1539 3.65441C17.4241 3.72264 17.7106 3.88695 17.8181 4.23381C17.8763 4.42122 17.8713 4.64093 17.8612 4.78275C17.8557 4.85982 17.8475 4.92756 17.8407 4.97617C17.8373 5.0006 17.8342 5.02054 17.8319 5.03479L17.829 5.05185L17.8281 5.05697L17.8278 5.05862L17.8276 5.05946C17.8276 5.05951 17.8276 5.05967 17.4998 4.9992L17.8276 5.05946C17.7942 5.2405 17.6203 5.3604 17.4393 5.327C17.2584 5.29363 17.1387 5.12 17.1719 4.9391"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.77882 6.55857C3.83811 6.73279 3.74499 6.92211 3.57078 6.98148C3.39653 7.04086 3.20713 6.94774 3.14774 6.77348L3.46323 6.66597C3.14774 6.77348 3.14777 6.77356 3.14774 6.77348L3.14716 6.77175L3.14616 6.76876L3.14299 6.7591C3.14038 6.75108 3.13683 6.73992 3.13255 6.72595C3.12399 6.69804 3.11247 6.65872 3.09978 6.61065C3.07458 6.51517 3.04391 6.38195 3.02326 6.23325C3.00283 6.08609 2.99073 5.91351 3.00806 5.74242C3.02512 5.57403 3.07338 5.38024 3.19659 5.21596C3.36782 4.98767 3.65782 4.85635 3.84492 4.7863C3.94807 4.74768 4.0425 4.71975 4.11102 4.70144C4.14549 4.69222 4.17396 4.6853 4.19437 4.68056L4.21882 4.67505L4.22612 4.67349L4.22846 4.673L4.2293 4.67283C4.22936 4.67281 4.2299 4.6727 4.29659 4.9993L4.2293 4.67283C4.40967 4.63599 4.58636 4.75223 4.62319 4.93261M4.36352 5.32584C4.36356 5.32583 4.36377 5.32579 4.36352 5.32584V5.32584Z"
fill="currentColor"
/>
</svg>`;
export const ellipseSvg = html`<svg
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="24" cy="24" r="23.5" fill="currentColor" stroke="inherit" />
</svg> `;
export const triangleSvg = html`<svg
width="62"
height="50"
viewBox="0 0 62 50"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.8 49.2L31 0.8L61.2 49.2H0.8Z"
fill="currentColor"
stroke="inherit"
/>
</svg> `;
export const roundedSvg = html`<svg
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.5"
y="0.5"
width="47"
height="47"
rx="5"
fill="currentColor"
stroke="inherit"
/>
</svg> `;
@@ -0,0 +1,355 @@
import {
CanvasElementType,
EdgelessCRUDIdentifier,
} from '@blocksuite/affine-block-surface';
import {
getShapeRadius,
getShapeType,
ShapeType,
} from '@blocksuite/affine-model';
import {
EditPropsStore,
TelemetryProvider,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { assertExists, SignalWatcher } from '@blocksuite/global/utils';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ShapeTool } from '../../../gfx-tool/shape-tool.js';
import { EdgelessDraggableElementController } from '../common/draggable/draggable-element.controller.js';
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
import { ellipseSvg, roundedSvg, triangleSvg } from './icons.js';
import type { DraggableShape } from './utils.js';
import { buildVariablesObject } from './utils.js';
const shapes: DraggableShape[] = [];
// to move shapes together
const oy = -2;
const ox = 0;
shapes.push({
name: 'roundedRect',
svg: roundedSvg,
style: {
default: { x: -9, y: 6 },
hover: { y: -5, z: 1 },
next: { y: 60 },
},
});
shapes.push({
name: ShapeType.Ellipse,
svg: ellipseSvg,
style: {
default: { x: -20, y: 31 },
hover: { y: 15, z: 1 },
next: { y: 64 },
},
});
shapes.push({
name: ShapeType.Triangle,
svg: triangleSvg,
style: {
default: { x: 18, y: 25 },
hover: { y: 7, z: 1 },
next: { y: 64 },
},
});
shapes.forEach(s => {
Object.values(s.style).forEach(style => {
if (style.y) (style.y as number) += oy;
if (style.x) (style.x as number) += ox;
});
});
export class EdgelessToolbarShapeDraggable extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
display: flex;
justify-content: center;
align-items: flex-end;
}
.edgeless-shape-draggable {
/* avoid shadow clipping */
--shadow-safe-area: 10px;
box-sizing: border-box;
flex-shrink: 0;
width: calc(100% + 2 * var(--shadow-safe-area));
height: calc(100% + var(--shadow-safe-area));
padding-top: var(--shadow-safe-area);
padding-left: var(--shadow-safe-area);
padding-right: var(--shadow-safe-area);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
position: relative;
pointer-events: none;
}
.shape {
width: fit-content;
height: fit-content;
position: absolute;
transition:
transform 0.3s,
z-index 0.1s;
transform: translateX(var(--default-x, 0)) translateY(var(--default-y, 0))
scale(var(--default-s, 1));
z-index: var(--default-z, 0);
pointer-events: none;
}
.shape svg {
display: block;
}
.shape svg path,
.shape svg circle,
.shape svg rect {
pointer-events: auto;
cursor: grab;
}
.shape:hover,
.shape.cancel {
transform: translateX(var(--hover-x, 0)) translateY(var(--hover-y, 0))
scale(var(--hover-s, 1));
z-index: var(--hover-z, 0);
}
.shape.next {
transition: all 0.5s cubic-bezier(0.39, 0.28, 0.09, 0.95);
pointer-events: none;
transform: translateX(var(--next-x, 0)) translateY(var(--next-y, 0))
scale(var(--next-s, 1));
}
.shape.next.coming {
transform: translateX(var(--default-x, 0)) translateY(var(--default-y, 0))
scale(var(--default-s, 1));
}
`;
draggableController!: EdgelessDraggableElementController<DraggableShape>;
draggingShape: DraggableShape['name'] = 'roundedRect';
override type = 'shape' as const;
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
get shapeShadow() {
return this.theme === 'dark'
? '0 0 7px rgba(0, 0, 0, .22)'
: '0 0 5px rgba(0, 0, 0, .2)';
}
private _setShapeOverlayLock(lock: boolean) {
const controller = this.edgeless.gfx.tool.currentTool$.peek();
if (controller instanceof ShapeTool) {
controller.setDisableOverlay(lock);
}
}
initDragController() {
if (!this.edgeless || !this.toolbarContainer) return;
if (this.draggableController) return;
this.draggableController = new EdgelessDraggableElementController(this, {
service: this.edgeless.service,
edgeless: this.edgeless,
scopeElement: this.toolbarContainer,
standardWidth: 100,
clickToDrag: true,
onOverlayCreated: (overlay, element) => {
const shapeName =
this.draggableController.states.draggingElement?.data.name;
if (!shapeName) return;
this.setEdgelessTool({
type: 'shape',
shapeName,
});
const controller = this.edgeless.gfx.tool.currentTool$.peek();
if (controller instanceof ShapeTool) {
controller.clearOverlay();
}
overlay.element.style.filter = `drop-shadow(${this.shapeShadow})`;
this.readyToDrop = true;
this.draggingShape = element.data.name;
},
onDrop: (el, bound) => {
const xywh = bound.serialize();
const shape = el.data;
const id = this.crud.addElement(CanvasElementType.SHAPE, {
shapeType: getShapeType(shape.name),
xywh,
radius: getShapeRadius(shape.name),
});
if (!id) return;
this.edgeless.std
.getOptional(TelemetryProvider)
?.track('CanvasElementAdded', {
control: 'toolbar:dnd',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'shape',
other: {
shapeType: getShapeType(shape.name),
},
});
this._setShapeOverlayLock(false);
this.readyToDrop = false;
this.edgeless.gfx.tool.setTool('default');
this.edgeless.gfx.selection.set({
elements: [id],
editing: false,
});
},
onCanceled: () => {
this._setShapeOverlayLock(false);
this.readyToDrop = false;
},
onElementClick: el => {
this.onShapeClick?.(el.data);
this._setShapeOverlayLock(true);
},
onEnterOrLeaveScope: (overlay, isOutside) => {
overlay.element.style.filter = isOutside
? 'none'
: `drop-shadow(${this.shapeShadow})`;
},
});
this.edgeless.bindHotKey(
{
s: ctx => {
// `page.keyboard.press('Shift+s')` in playwright will also trigger this 's' key event
if (ctx.get('keyboardState').raw.shiftKey) return;
const service = this.edgeless.service;
if (service.locked || service.selection.editing) return;
if (this.readyToDrop) {
const activeIndex = shapes.findIndex(
s => s.name === this.draggingShape
);
const nextIndex = (activeIndex + 1) % shapes.length;
const next = shapes[nextIndex];
this.draggingShape = next.name;
this.draggableController.cancelWithoutAnimation();
}
const el = this.shapeContainer.querySelector(
`.shape.${this.draggingShape}`
) as HTMLElement;
assertExists(el, 'Edgeless toolbar Shape element not found');
const { x, y } = service.gfx.tool.lastMousePos$.peek();
const { left, top } = this.edgeless.viewport;
const clientPos = { x: x + left, y: y + top };
this.draggableController.clickToDrag(el, clientPos);
},
},
{ global: true }
);
}
override render() {
const { cancelled, dragOut, draggingElement } =
this.draggableController?.states || {};
const draggingShape = draggingElement?.data;
return html`<div class="edgeless-shape-draggable">
${repeat(
shapes,
s => s.name,
shape => {
const isBeingDragged = draggingShape?.name === shape.name;
const { fillColor, strokeColor } =
this.edgeless.std.get(EditPropsStore).lastProps$.value[
`shape:${shape.name}`
] || {};
const color = this.edgeless.std
.get(ThemeProvider)
.generateColorProperty(fillColor);
const stroke = this.edgeless.std
.get(ThemeProvider)
.generateColorProperty(strokeColor);
const baseStyle = {
...buildVariablesObject(shape.style),
filter: `drop-shadow(${this.shapeShadow})`,
color,
stroke,
};
const currStyle = styleMap({
...baseStyle,
opacity: isBeingDragged ? 0 : 1,
});
const nextStyle = styleMap(baseStyle);
return html`${isBeingDragged
? html`<div
style=${nextStyle}
class=${classMap({
shape: true,
next: true,
coming: !!dragOut && !cancelled,
})}
>
${shape.svg}
</div>`
: nothing}
<div
style=${currStyle}
class=${classMap({
shape: true,
[shape.name]: true,
cancel: isBeingDragged && !dragOut,
})}
@mousedown=${(e: MouseEvent) =>
this.draggableController.onMouseDown(e, {
data: shape,
preview: shape.svg,
})}
@touchstart=${(e: TouchEvent) =>
this.draggableController.onTouchStart(e, {
data: shape,
preview: shape.svg,
})}
@click=${(e: MouseEvent) => e.stopPropagation()}
>
${shape.svg}
</div>`;
}
)}
</div>`;
}
override updated(_changedProperties: Map<PropertyKey, unknown>) {
const controllerRequiredProps = ['edgeless', 'toolbarContainer'] as const;
if (
controllerRequiredProps.some(p => _changedProperties.has(p)) &&
!this.draggableController
) {
this.initDragController();
}
}
@property({ attribute: false })
accessor onShapeClick: (shape: DraggableShape) => void = () => {};
@state()
accessor readyToDrop = false;
@query('.edgeless-shape-draggable')
accessor shapeContainer!: HTMLDivElement;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-toolbar-shape-draggable': EdgelessToolbarShapeDraggable;
}
}
@@ -0,0 +1,72 @@
import { ShapeType } from '@blocksuite/affine-model';
import {
DiamondIcon,
EllipseIcon,
RoundedRectangleIcon,
SquareIcon,
TriangleIcon,
} from '@blocksuite/icons/lit';
import type { TemplateResult } from 'lit';
import type { ShapeToolOption } from '../../../gfx-tool/shape-tool';
import {
ScribbledDiamondIcon,
ScribbledEllipseIcon,
ScribbledRoundedRectangleIcon,
ScribbledSquareIcon,
ScribbledTriangleIcon,
} from './icons';
type Config = {
name: ShapeToolOption['shapeName'];
generalIcon: TemplateResult<1>;
scribbledIcon: TemplateResult<1>;
tooltip: string;
disabled: boolean;
};
export const ShapeComponentConfig: Config[] = [
{
name: ShapeType.Rect,
generalIcon: SquareIcon(),
scribbledIcon: ScribbledSquareIcon,
tooltip: 'Square',
disabled: false,
},
{
name: ShapeType.Ellipse,
generalIcon: EllipseIcon(),
scribbledIcon: ScribbledEllipseIcon,
tooltip: 'Ellipse',
disabled: false,
},
{
name: ShapeType.Diamond,
generalIcon: DiamondIcon(),
scribbledIcon: ScribbledDiamondIcon,
tooltip: 'Diamond',
disabled: false,
},
{
name: ShapeType.Triangle,
generalIcon: TriangleIcon(),
scribbledIcon: ScribbledTriangleIcon,
tooltip: 'Triangle',
disabled: false,
},
{
name: 'roundedRect',
generalIcon: RoundedRectangleIcon(),
scribbledIcon: ScribbledRoundedRectangleIcon,
tooltip: 'Rounded rectangle',
disabled: false,
},
];
export const ShapeComponentConfigMap = ShapeComponentConfig.reduce(
(acc, config) => {
acc[config.name] = config;
return acc;
},
{} as Record<Config['name'], Config>
);
@@ -0,0 +1,195 @@
import {
DefaultTheme,
isTransparent,
type Palette,
type ShapeName,
ShapeStyle,
ShapeType,
} from '@blocksuite/affine-model';
import {
EditPropsStore,
FeatureFlagService,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { StyleGeneralIcon, StyleScribbleIcon } from '@blocksuite/icons/lit';
import { computed, effect, type Signal, signal } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
import { ShapeComponentConfig } from './shape-menu-config.js';
export class EdgelessShapeMenu extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = css`
:host {
display: flex;
z-index: -1;
}
.menu-content {
display: flex;
align-items: center;
}
.shape-type-container,
.shape-style-container {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
}
.shape-type-container svg,
.shape-style-container svg {
fill: var(--affine-icon-color);
stroke: none;
}
menu-divider {
height: 24px;
margin: 0 9px;
}
`;
private readonly _shapeName$: Signal<ShapeName> = signal(ShapeType.Rect);
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
private readonly _props$ = computed(() => {
const shapeName: ShapeName = this._shapeName$.value;
const { shapeStyle, fillColor, strokeColor, radius } =
this.edgeless.std.get(EditPropsStore).lastProps$.value[
`shape:${shapeName}`
];
return {
shapeStyle,
shapeName,
fillColor,
strokeColor,
radius,
};
});
private readonly _setFillColor = ({ key, value }: Palette) => {
const filled = !isTransparent(value);
const fillColor = value;
const strokeColor = filled
? DefaultTheme.StrokeColorPalettes.find(palette => palette.key === key)
?.value
: DefaultTheme.StrokeColorMap.Grey;
const { shapeName } = this._props$.value;
this.edgeless.std
.get(EditPropsStore)
.recordLastProps(`shape:${shapeName}`, {
filled,
fillColor,
strokeColor,
});
this.onChange(shapeName);
};
private readonly _setShapeStyle = (shapeStyle: ShapeStyle) => {
const { shapeName } = this._props$.value;
this.edgeless.std
.get(EditPropsStore)
.recordLastProps(`shape:${shapeName}`, {
shapeStyle,
});
this.onChange(shapeName);
};
private readonly _theme$ = computed(() => {
return this.edgeless.std.get(ThemeProvider).theme$.value;
});
override connectedCallback(): void {
super.connectedCallback();
this._disposables.add(
effect(() => {
const value = this.edgeless.gfx.tool.currentToolOption$.value;
if (value && value.type === 'shape') {
this._shapeName$.value = value.shapeName;
}
})
);
}
override render() {
const { fillColor, shapeStyle, shapeName } = this._props$.value;
return html`
<edgeless-slide-menu>
<div class="menu-content">
<div class="shape-style-container">
<edgeless-tool-icon-button
.tooltip=${'General'}
.active=${shapeStyle === ShapeStyle.General}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${() => {
this._setShapeStyle(ShapeStyle.General);
}}
>
${StyleGeneralIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.tooltip=${'Scribbled'}
.active=${shapeStyle === ShapeStyle.Scribbled}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${() => {
this._setShapeStyle(ShapeStyle.Scribbled);
}}
>
${StyleScribbleIcon()}
</edgeless-tool-icon-button>
</div>
<menu-divider .vertical=${true}></menu-divider>
<div class="shape-type-container">
${ShapeComponentConfig.map(
({ name, generalIcon, scribbledIcon, tooltip }) => {
return html`
<edgeless-tool-icon-button
.tooltip=${tooltip}
.active=${shapeName === name}
.activeMode=${'background'}
.iconSize=${'20px'}
@click=${() => this.onChange(name)}
>
${shapeStyle === ShapeStyle.General
? generalIcon
: scribbledIcon}
</edgeless-tool-icon-button>
`;
}
)}
</div>
<menu-divider .vertical=${true}></menu-divider>
<edgeless-color-panel
class="one-way"
.value=${fillColor}
.theme=${this._theme$.value}
.palettes=${DefaultTheme.FillColorPalettes}
.hasTransparent=${!this.edgeless.doc
.get(FeatureFlagService)
.getFlag('enable_color_picker')}
@select=${(e: ColorEvent) => this._setFillColor(e.detail)}
></edgeless-color-panel>
</div>
</edgeless-slide-menu>
`;
}
@property({ attribute: false })
accessor onChange!: (name: ShapeName) => void;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-shape-menu': EdgelessShapeMenu;
}
}
@@ -0,0 +1,92 @@
import { type ShapeName, ShapeType } from '@blocksuite/affine-model';
import { SignalWatcher } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
import { ShapeTool } from '../../../gfx-tool/shape-tool.js';
import { getTooltipWithShortcut } from '../../utils.js';
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
import type { DraggableShape } from './utils.js';
export class EdgelessShapeToolButton extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
display: block;
width: 100%;
height: 100%;
}
edgeless-toolbar-button,
.shapes {
width: 100%;
height: 64px;
}
`;
private readonly _handleShapeClick = (shape: DraggableShape) => {
this.setEdgelessTool(this.type, {
shapeName: shape.name,
});
if (!this.popper) this._toggleMenu();
};
private readonly _handleWrapperClick = () => {
if (this.tryDisposePopper()) return;
this.setEdgelessTool(this.type, {
shapeName: ShapeType.Rect,
});
if (!this.popper) this._toggleMenu();
};
override type = 'shape' as const;
private _toggleMenu() {
this.createPopper('edgeless-shape-menu', this, {
setProps: ele => {
ele.edgeless = this.edgeless;
ele.onChange = (shapeName: ShapeName) => {
this.setEdgelessTool(this.type, {
shapeName,
});
this._updateOverlay();
};
},
});
}
private _updateOverlay() {
const controller = this.edgeless.gfx.tool.currentTool$.peek();
if (controller instanceof ShapeTool) {
controller.createOverlay();
}
}
override render() {
const { active } = this;
return html`
<edgeless-toolbar-button
class="edgeless-shape-button"
.tooltip=${this.popper ? '' : getTooltipWithShortcut('Shape', 'S')}
.tooltipOffset=${5}
.active=${active}
>
<edgeless-toolbar-shape-draggable
.edgeless=${this.edgeless}
.toolbarContainer=${this.toolbarContainer}
class="shapes"
@click=${this._handleWrapperClick}
.onShapeClick=${this._handleShapeClick}
>
</edgeless-toolbar-shape-draggable>
</edgeless-toolbar-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-shape-tool-button': EdgelessShapeToolButton;
}
}
@@ -0,0 +1,324 @@
import {
CanvasElementType,
EdgelessCRUDIdentifier,
} from '@blocksuite/affine-block-surface';
import {
getShapeRadius,
getShapeType,
type ShapeName,
type ShapeStyle,
} from '@blocksuite/affine-model';
import { Bound, sleep, WithDisposable } from '@blocksuite/global/utils';
import {
css,
html,
LitElement,
type PropertyValues,
type TemplateResult,
} from 'lit';
import { property, query, state } from 'lit/decorators.js';
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
import { ShapeTool } from '../../../gfx-tool/shape-tool.js';
export interface Shape {
name: ShapeName;
svg: TemplateResult<1>;
}
interface Coord {
x: number;
y: number;
}
type TransformMap = Record<
string,
{
x: number;
y: number;
scale: number;
origin: string;
}
>;
export class EdgelessShapeToolElement extends WithDisposable(LitElement) {
static override styles = css`
.shape {
--x: 0px;
--y: 0px;
--offset-x: 0px;
--offset-y: 0px;
--scale: 1;
transform: translateX(calc(var(--offset-x) + var(--x)))
translateY(calc(var(--y) + var(--offset-y))) scale(var(--scale));
height: 60px;
width: 60px;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 12px;
left: 16px;
transition: all 0.5s cubic-bezier(0, -0.01, 0.01, 1.01);
}
.shape.dragging {
transition: none;
}
.shape svg {
height: 100%;
filter: drop-shadow(0px 2px 8px rgba(0, 0, 0, 0.15));
}
`;
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private readonly _addShape = (coord: Coord, padding: Coord) => {
const width = 100;
const height = 100;
const { x: edgelessX, y: edgelessY } =
this.edgeless.getBoundingClientRect();
const zoom = this.edgeless.service.viewport.zoom;
const [modelX, modelY] = this.edgeless.service.viewport.toModelCoord(
coord.x - edgelessX - width * padding.x * zoom,
coord.y - edgelessY - height * padding.y * zoom
);
const xywh = new Bound(modelX, modelY, width, height).serialize();
this.crud.addElement(CanvasElementType.SHAPE, {
shapeType: getShapeType(this.shape.name),
xywh: xywh,
radius: getShapeRadius(this.shape.name),
});
};
private readonly _onDragEnd = async (coord: Coord) => {
if (this._startCoord.x === coord.x && this._startCoord.y === coord.y) {
this.handleClick();
this._dragging = false;
return;
}
if (!this._dragging) {
return;
}
this._dragging = false;
this.edgeless.gfx.tool.setTool('default');
if (this._isOutside) {
const rect = this._shapeElement.getBoundingClientRect();
this._backupShapeElement.style.setProperty('transition', 'none');
this._backupShapeElement.style.setProperty('--y', '100px');
this._shapeElement.style.setProperty('--offset-x', `${0}px`);
this._shapeElement.style.setProperty('--offset-y', `${0}px`);
await sleep(0);
this._shapeElement.classList.remove('dragging');
this._backupShapeElement.style.removeProperty('transition');
const padding = {
x: (coord.x - rect.left) / rect.width,
y: (coord.y - rect.top) / rect.height,
};
this._addShape(coord, padding);
} else {
this._shapeElement.classList.remove('dragging');
this._shapeElement.style.setProperty('--offset-x', `${0}px`);
this._shapeElement.style.setProperty('--offset-y', `${0}px`);
this._backupShapeElement.style.setProperty('--y', '100px');
}
};
private readonly _onDragMove = (coord: Coord) => {
if (!this._dragging) {
return;
}
const controller = this.edgeless.gfx.tool.currentTool$.peek();
if (controller instanceof ShapeTool) {
controller.clearOverlay();
}
const { x, y } = coord;
this._shapeElement.style.setProperty(
'--offset-x',
`${x - this._startCoord.x}px`
);
this._shapeElement.style.setProperty(
'--offset-y',
`${y - this._startCoord.y}px`
);
const containerRect = this.getContainerRect();
const isOut =
y < containerRect.top ||
x < containerRect.left ||
x > containerRect.right;
if (isOut !== this._isOutside) {
this._backupShapeElement.style.setProperty(
'--y',
isOut ? '5px' : '100px'
);
this._backupShapeElement.style.setProperty(
'--scale',
isOut ? '1' : '0.9'
);
}
this._isOutside = isOut;
};
private readonly _onDragStart = (coord: Coord) => {
this._startCoord = { x: coord.x, y: coord.y };
if (this.order !== 1) {
return;
}
this._dragging = true;
this._shapeElement.classList.add('dragging');
};
private readonly _onMouseMove = (event: MouseEvent) => {
if (!this._dragging) {
return;
}
this._onDragMove({ x: event.clientX, y: event.clientY });
};
private readonly _onMouseUp = (event: MouseEvent) => {
this._onDragEnd({ x: event.clientX, y: event.clientY }).catch(
console.error
);
};
private readonly _onTouchEnd = (event: TouchEvent) => {
if (!event.changedTouches.length) return;
this._onDragEnd({
// https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent#touchend
x: event.changedTouches[0].clientX,
y: event.changedTouches[0].clientY,
}).catch(console.error);
};
private readonly _touchMove = (event: TouchEvent) => {
if (!this._dragging) {
return;
}
this._onDragMove({
x: event.touches[0].clientX,
y: event.touches[0].clientY,
});
};
private readonly _transformMap: TransformMap = {
z1: { x: 0, y: 5, scale: 1.1, origin: '50% 100%' },
z2: { x: -15, y: 0, scale: 0.75, origin: '20% 20%' },
z3: { x: 15, y: 0, scale: 0.75, origin: '80% 20%' },
hidden: { x: 0, y: 120, scale: 0, origin: '50% 50%' },
};
override connectedCallback() {
super.connectedCallback();
this._disposables.addFromEvent(
this.edgeless.host,
'mousemove',
this._onMouseMove
);
this._disposables.addFromEvent(
this.edgeless.host,
'touchmove',
this._touchMove
);
this._disposables.addFromEvent(
this.edgeless.host,
'mouseup',
this._onMouseUp
);
this._disposables.addFromEvent(
this.edgeless.host,
'touchend',
this._onTouchEnd
);
}
override render() {
return html`
<div
id="shape-tool-element"
class="shape"
@mousedown=${(event: MouseEvent) =>
this._onDragStart({ x: event.clientX, y: event.clientY })}
@touchstart=${(event: TouchEvent) => {
event.preventDefault();
this._onDragStart({
x: event.touches[0].clientX,
y: event.touches[0].clientY,
});
}}
>
${this.shape.svg}
</div>
${this.order === 1
? html`<div id="backup-shape-element" class="shape">
${this.shape.svg}
</div>`
: null}
`;
}
override updated(changedProperties: PropertyValues<this>) {
if (!changedProperties.has('shape') && !changedProperties.has('order')) {
return;
}
const transform =
this._transformMap[this.order <= 3 ? `z${this.order}` : 'hidden'];
this._shapeElement.style.setProperty('--x', `${transform.x}px`);
this._shapeElement.style.setProperty('--y', `${transform.y}px`);
this._shapeElement.style.setProperty(
'--scale',
String(transform.scale || 1)
);
this._shapeElement.style.zIndex = String(999 - this.order);
this._shapeElement.style.transformOrigin = transform.origin;
if (this._backupShapeElement) {
this._backupShapeElement.style.setProperty('--y', '100px');
this._backupShapeElement.style.setProperty('--scale', '0.9');
this._backupShapeElement.style.zIndex = '999';
}
}
@query('#backup-shape-element')
private accessor _backupShapeElement!: HTMLElement;
@state()
private accessor _dragging: boolean = false;
@state()
private accessor _isOutside: boolean = false;
@query('#shape-tool-element')
private accessor _shapeElement!: HTMLElement;
@state()
private accessor _startCoord: Coord = { x: -1, y: -1 };
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor getContainerRect!: () => DOMRect;
@property({ attribute: false })
accessor handleClick!: () => void;
@property({ attribute: false })
accessor order!: number;
@property({ attribute: false })
accessor shape!: Shape;
@property({ attribute: false })
accessor shapeStyle!: ShapeStyle;
@property({ attribute: false })
accessor shapeType!: ShapeName;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-shape-tool-element': EdgelessShapeToolElement;
}
}

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