refactor(editor): unify directories naming (#11516)

**Directory Structure Changes**

- Renamed multiple block-related directories by removing the "block-" prefix:
  - `block-attachment` → `attachment`
  - `block-bookmark` → `bookmark`
  - `block-callout` → `callout`
  - `block-code` → `code`
  - `block-data-view` → `data-view`
  - `block-database` → `database`
  - `block-divider` → `divider`
  - `block-edgeless-text` → `edgeless-text`
  - `block-embed` → `embed`
This commit is contained in:
Saul-Mirone
2025-04-07 12:34:40 +00:00
parent e1bd2047c4
commit 1f45cc5dec
893 changed files with 439 additions and 460 deletions

View File

@@ -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,
];

View File

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

View File

@@ -0,0 +1,3 @@
export * from './html.js';
export * from './markdown.js';
export * from './notion-html.js';

View File

@@ -0,0 +1,36 @@
import { RootBlockSchema } from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
import type { DeltaInsert } from '@blocksuite/store';
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
);

View File

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

View File

@@ -0,0 +1,2 @@
export * from './page-clipboard.js';
export * from './readonly-clipboard.js';

View File

@@ -0,0 +1,171 @@
import { deleteTextCommand } from '@blocksuite/affine-inline-preset';
import {
pasteMiddleware,
replaceIdMiddleware,
surfaceRefToEmbed,
} from '@blocksuite/affine-shared/adapters';
import {
clearAndSelectFirstModelCommand,
deleteSelectedModelsCommand,
getBlockIndexCommand,
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSelectedModelsCommand,
getTextSelectionCommand,
retainFirstModelCommand,
} from '@blocksuite/affine-shared/commands';
import { DisposableGroup } from '@blocksuite/global/disposable';
import type { UIEventHandler } from '@blocksuite/std';
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 {
static override key = 'affine-page-clipboard';
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 mounted() {
if (!navigator.clipboard) {
console.error(
'navigator.clipboard is not supported in current environment.'
);
return;
}
if (this._disposables.disposed) {
this._disposables = new DisposableGroup();
}
this.std.event.add('copy', this.onPageCopy);
this.std.event.add('paste', this.onPagePaste);
this.std.event.add('cut', this.onPageCut);
this._init();
}
}

View File

@@ -0,0 +1,137 @@
import { defaultImageProxyMiddleware } from '@blocksuite/affine-block-image';
import {
AttachmentAdapter,
ClipboardAdapter,
copyMiddleware,
HtmlAdapter,
ImageAdapter,
MixTextAdapter,
NotionTextAdapter,
titleMiddleware,
} from '@blocksuite/affine-shared/adapters';
import {
copySelectedModelsCommand,
draftSelectedModelsCommand,
getSelectedModelsCommand,
} from '@blocksuite/affine-shared/commands';
import { DisposableGroup } from '@blocksuite/global/disposable';
import {
ClipboardAdapterConfigExtension,
LifeCycleWatcher,
type UIEventHandler,
} from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
const SnapshotClipboardConfig = ClipboardAdapterConfigExtension({
mimeType: ClipboardAdapter.MIME,
adapter: ClipboardAdapter,
priority: 100,
});
const NotionClipboardConfig = ClipboardAdapterConfigExtension({
mimeType: 'text/_notion-text-production',
adapter: NotionTextAdapter,
priority: 95,
});
const HtmlClipboardConfig = ClipboardAdapterConfigExtension({
mimeType: 'text/html',
adapter: HtmlAdapter,
priority: 90,
});
const imageClipboardConfigs = [
'image/apng',
'image/avif',
'image/gif',
'image/jpeg',
'image/png',
'image/svg+xml',
'image/webp',
].map(mimeType => {
return ClipboardAdapterConfigExtension({
mimeType,
adapter: ImageAdapter,
priority: 80,
});
});
const PlainTextClipboardConfig = ClipboardAdapterConfigExtension({
mimeType: 'text/plain',
adapter: MixTextAdapter,
priority: 70,
});
const AttachmentClipboardConfig = ClipboardAdapterConfigExtension({
mimeType: '*/*',
adapter: AttachmentAdapter,
priority: 60,
});
export const clipboardConfigs: ExtensionType[] = [
SnapshotClipboardConfig,
NotionClipboardConfig,
HtmlClipboardConfig,
...imageClipboardConfigs,
PlainTextClipboardConfig,
AttachmentClipboardConfig,
];
/**
* 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 extends LifeCycleWatcher {
static override key = 'affine-readonly-clipboard';
protected readonly _copySelected = (onCopy?: () => void) => {
return this.std.command
.chain()
.with({ onCopy })
.pipe(getSelectedModelsCommand)
.pipe(draftSelectedModelsCommand)
.pipe(copySelectedModelsCommand);
};
protected _disposables = new DisposableGroup();
protected _initAdapters = () => {
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.unuse(copy);
this.std.clipboard.unuse(
titleMiddleware(this.std.store.workspace.meta.docMetas)
);
this.std.clipboard.unuse(defaultImageProxyMiddleware);
},
});
};
onPageCopy: UIEventHandler = ctx => {
const e = ctx.get('clipboardState').raw;
e.preventDefault();
this._copySelected().run();
};
override mounted(): void {
if (!navigator.clipboard) {
console.error(
'navigator.clipboard is not supported in current environment.'
);
return;
}
if (this._disposables.disposed) {
this._disposables = new DisposableGroup();
}
this.std.event.add('copy', this.onPageCopy);
this._initAdapters();
}
}

View File

@@ -0,0 +1,104 @@
import { FileDropExtension } from '@blocksuite/affine-components/drop-indicator';
import { BrushElementRendererExtension } from '@blocksuite/affine-gfx-brush';
import {
ConnectorElementRendererExtension,
ConnectorElementView,
} from '@blocksuite/affine-gfx-connector';
import {
GroupElementRendererExtension,
GroupElementView,
} from '@blocksuite/affine-gfx-group';
import {
MindmapElementRendererExtension,
MindMapView,
} from '@blocksuite/affine-gfx-mindmap';
import {
HighlighterElementRendererExtension,
ShapeElementRendererExtension,
ShapeElementView,
} from '@blocksuite/affine-gfx-shape';
import {
TextElementRendererExtension,
TextElementView,
} from '@blocksuite/affine-gfx-text';
import { NoteBlockSchema } from '@blocksuite/affine-model';
import {
DNDAPIExtension,
DocModeService,
EmbedOptionService,
PageViewportServiceExtension,
ThemeService,
ToolbarModuleExtension,
ToolbarRegistryExtension,
} from '@blocksuite/affine-shared/services';
import { dragHandleWidget } from '@blocksuite/affine-widget-drag-handle';
import { docRemoteSelectionWidget } from '@blocksuite/affine-widget-remote-selection';
import { scrollAnchoringWidget } from '@blocksuite/affine-widget-scroll-anchoring';
import { SlashMenuExtension } from '@blocksuite/affine-widget-slash-menu';
import { toolbarWidget } from '@blocksuite/affine-widget-toolbar';
import { BlockFlavourIdentifier, FlavourExtension } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { RootBlockAdapterExtensions } from '../adapters/extension';
import { clipboardConfigs } from '../clipboard';
import { builtinToolbarConfig } from '../configs/toolbar';
import {
innerModalWidget,
linkedDocWidget,
modalWidget,
viewportOverlayWidget,
} from './widgets';
/**
* Why do we add these extensions into CommonSpecs?
* Because in some cases we need to create edgeless elements in page mode.
* And these view may contain some logic when elements initialize.
*/
const EdgelessElementViews = [
ConnectorElementView,
MindMapView,
GroupElementView,
TextElementView,
ShapeElementView,
];
export const EdgelessElementRendererExtension: ExtensionType[] = [
BrushElementRendererExtension,
HighlighterElementRendererExtension,
ShapeElementRendererExtension,
TextElementRendererExtension,
ConnectorElementRendererExtension,
GroupElementRendererExtension,
MindmapElementRendererExtension,
];
export const CommonSpecs: ExtensionType[] = [
FlavourExtension('affine:page'),
DocModeService,
ThemeService,
EmbedOptionService,
PageViewportServiceExtension,
DNDAPIExtension,
FileDropExtension,
ToolbarRegistryExtension,
...RootBlockAdapterExtensions,
...clipboardConfigs,
...EdgelessElementViews,
...EdgelessElementRendererExtension,
modalWidget,
innerModalWidget,
SlashMenuExtension,
linkedDocWidget,
dragHandleWidget,
docRemoteSelectionWidget,
viewportOverlayWidget,
scrollAnchoringWidget,
toolbarWidget,
ToolbarModuleExtension({
id: BlockFlavourIdentifier(NoteBlockSchema.model.flavour),
config: builtinToolbarConfig,
}),
];
export * from './widgets';

View File

@@ -0,0 +1,28 @@
import { WidgetViewExtension } from '@blocksuite/std';
import { literal, unsafeStatic } from 'lit/static-html.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_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 linkedDocWidget = WidgetViewExtension(
'affine:page',
AFFINE_LINKED_DOC_WIDGET,
literal`${unsafeStatic(AFFINE_LINKED_DOC_WIDGET)}`
);
export const viewportOverlayWidget = WidgetViewExtension(
'affine:page',
AFFINE_VIEWPORT_OVERLAY_WIDGET,
literal`${unsafeStatic(AFFINE_VIEWPORT_OVERLAY_WIDGET)}`
);

View File

@@ -0,0 +1,373 @@
import {
convertToDatabase,
DATABASE_CONVERT_WHITE_LIST,
} from '@blocksuite/affine-block-database';
import {
convertSelectedBlocksToLinkedDoc,
getTitleFromSelectedModels,
notifyDocCreated,
promptDocTitle,
} from '@blocksuite/affine-block-embed';
import { updateBlockType } from '@blocksuite/affine-block-note';
import { toast } from '@blocksuite/affine-components/toast';
import {
deleteTextCommand,
formatBlockCommand,
formatNativeCommand,
formatTextCommand,
isFormatSupported,
textFormatConfigs,
} from '@blocksuite/affine-inline-preset';
import { textConversionConfigs } from '@blocksuite/affine-rich-text';
import {
copySelectedModelsCommand,
deleteSelectedModelsCommand,
draftSelectedModelsCommand,
duplicateSelectedModelsCommand,
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSelectedBlocksCommand,
getSelectedModelsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type {
ToolbarAction,
ToolbarActionGenerator,
ToolbarActionGroup,
ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { ActionPlacement } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
import {
ArrowDownSmallIcon,
CopyIcon,
DatabaseTableViewIcon,
DeleteIcon,
DuplicateIcon,
LinkedPageIcon,
} from '@blocksuite/icons/lit';
import { type BlockComponent, BlockSelection } from '@blocksuite/std';
import { toDraftModel } from '@blocksuite/store';
import { html } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
const conversionsActionGroup = {
id: 'a.conversions',
when: ({ chain }) => isFormatSupported(chain).run()[0],
generate({ chain }) {
const [ok, { selectedModels = [] }] = chain
.tryAll(chain => [
chain.pipe(getTextSelectionCommand),
chain.pipe(getBlockSelectionsCommand),
])
.pipe(getSelectedModelsCommand, { types: ['text', 'block'] })
.run();
// only support model with text
// TODO(@fundon): displays only in a single paragraph, `length === 1`.
const allowed = ok && selectedModels.filter(model => model.text).length > 0;
if (!allowed) return null;
const model = selectedModels[0];
const conversion =
textConversionConfigs.find(
({ flavour, type }) =>
flavour === model.flavour &&
(type ? 'type' in model.props && type === model.props.type : true)
) ?? textConversionConfigs[0];
const update = (flavour: string, type?: string) => {
chain
.pipe(updateBlockType, {
flavour,
...(type && { props: { type } }),
})
.run();
};
return {
content: html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button
aria-label="Conversions"
.tooltip="${'Turn into'}"
>
${conversion.icon} ${ArrowDownSmallIcon()}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${repeat(
textConversionConfigs.filter(c => c.flavour !== 'affine:divider'),
item => item.name,
({ flavour, type, name, icon }) => html`
<editor-menu-action
aria-label=${name}
?data-selected=${conversion.name === name}
@click=${() => update(flavour, type)}
>
${icon}<span class="label">${name}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`,
};
},
} as const satisfies ToolbarActionGenerator;
const inlineTextActionGroup = {
id: 'b.inline-text',
when: ({ chain }) => isFormatSupported(chain).run()[0],
actions: textFormatConfigs.map(
({ id, name, action, activeWhen, icon }, score) => {
return {
id,
icon,
score,
tooltip: name,
run: ({ host }) => action(host),
active: ({ host }) => activeWhen(host),
};
}
),
} as const satisfies ToolbarActionGroup;
const highlightActionGroup = {
id: 'c.highlight',
when: ({ chain }) => isFormatSupported(chain).run()[0],
content({ chain }) {
const updateHighlight = (styles: AffineTextAttributes) => {
const payload = { styles };
chain
.try(chain => [
chain.pipe(getTextSelectionCommand).pipe(formatTextCommand, payload),
chain
.pipe(getBlockSelectionsCommand)
.pipe(formatBlockCommand, payload),
chain.pipe(formatNativeCommand, payload),
])
.run();
};
return html`
<affine-highlight-dropdown-menu
.updateHighlight=${updateHighlight}
></affine-highlight-dropdown-menu>
`;
},
} as const satisfies ToolbarAction;
const turnIntoDatabase = {
id: 'd.convert-to-database',
tooltip: 'Create Table',
icon: DatabaseTableViewIcon(),
when({ chain }) {
const middleware = (count = 0) => {
return (ctx: { selectedBlocks: BlockComponent[] }, next: () => void) => {
const { selectedBlocks } = ctx;
if (!selectedBlocks || selectedBlocks.length === count) return;
const allowed = selectedBlocks.every(block =>
DATABASE_CONVERT_WHITE_LIST.includes(block.flavour)
);
if (!allowed) return;
next();
};
};
let [ok] = chain
.pipe(getTextSelectionCommand)
.pipe(getSelectedBlocksCommand, {
types: ['text'],
})
.pipe(middleware(1))
.run();
if (ok) return true;
[ok] = chain
.tryAll(chain => [
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedBlocksCommand, {
types: ['block', 'image'],
})
.pipe(middleware(0))
.run();
return ok;
},
run({ host }) {
convertToDatabase(host, tableViewMeta.type);
},
} as const satisfies ToolbarAction;
const turnIntoLinkedDoc = {
id: 'e.convert-to-linked-doc',
tooltip: 'Create Linked Doc',
icon: LinkedPageIcon(),
when({ chain }) {
const [ok, { selectedModels }] = chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'text'],
mode: 'flat',
})
.run();
return ok && Boolean(selectedModels?.length);
},
run({ chain, store, selection, std, track }) {
const [ok, { draftedModels, selectedModels }] = chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'text'],
mode: 'flat',
})
.pipe(draftSelectedModelsCommand)
.run();
if (!ok || !draftedModels || !selectedModels?.length) return;
selection.clear();
const autofill = getTitleFromSelectedModels(
selectedModels.map(toDraftModel)
);
promptDocTitle(std, autofill)
.then(async title => {
if (title === null) return;
await convertSelectedBlocksToLinkedDoc(
std,
store,
draftedModels,
title
);
notifyDocCreated(std, store);
track('DocCreated', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
control: 'create linked doc',
type: 'embed-linked-doc',
});
track('LinkedDocCreated', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
control: 'create linked doc',
type: 'embed-linked-doc',
});
})
.catch(console.error);
},
} as const satisfies ToolbarAction;
export const builtinToolbarConfig = {
actions: [
conversionsActionGroup,
inlineTextActionGroup,
highlightActionGroup,
turnIntoDatabase,
turnIntoLinkedDoc,
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run({ chain, host }) {
const [ok] = chain
.pipe(getSelectedModelsCommand)
.pipe(draftSelectedModelsCommand)
.pipe(copySelectedModelsCommand)
.run();
if (!ok) return;
toast(host, 'Copied to clipboard');
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run({ chain, store, selection }) {
store.captureSync();
const [ok, { selectedBlocks = [] }] = chain
.pipe(getTextSelectionCommand)
.pipe(getSelectedBlocksCommand, {
types: ['text'],
mode: 'highest',
})
.run();
// If text selection exists, convert to block selection
if (ok && selectedBlocks.length) {
selection.setGroup(
'note',
selectedBlocks.map(block =>
selection.create(BlockSelection, {
blockId: block.model.id,
})
)
);
}
chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'image'],
mode: 'highest',
})
.pipe(draftSelectedModelsCommand)
.pipe(duplicateSelectedModelsCommand)
.run();
},
},
],
when(ctx) {
return !ctx.flags.isNative();
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
actions: [
{
id: 'delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run({ chain }) {
// removes text
const [ok] = chain
.pipe(getTextSelectionCommand)
.pipe(deleteTextCommand)
.run();
if (ok) return;
// removes blocks
chain
.tryAll(chain => [
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedModelsCommand)
.pipe(deleteSelectedModelsCommand)
.run();
},
},
],
when(ctx) {
return !ctx.flags.isNative();
},
},
],
} as const satisfies ToolbarModuleConfig;

View File

@@ -0,0 +1,117 @@
import {
CanvasElementType,
type ClipboardConfigCreationContext,
EdgelessCRUDIdentifier,
} from '@blocksuite/affine-block-surface';
import type { Connection } from '@blocksuite/affine-model';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import { Bound, type SerializedXYWH, Vec } from '@blocksuite/global/gfx';
import type { BlockStdScope } from '@blocksuite/std';
import type {
GfxPrimitiveElementModel,
SerializedElement,
} from '@blocksuite/std/gfx';
import * as Y from 'yjs';
const { GROUP, MINDMAP, CONNECTOR } = CanvasElementType;
export function createCanvasElement(
std: BlockStdScope,
clipboardData: SerializedElement,
context: ClipboardConfigCreationContext,
newXYWH: SerializedXYWH
) {
if (clipboardData.type === GROUP) {
const yMap = new Y.Map();
const children = clipboardData.children ?? {};
for (const [key, value] of Object.entries(children)) {
const newKey = context.oldToNewIdMap.get(key);
if (!newKey) {
console.error(
`Copy failed: cannot find the copied child in group, key: ${key}`
);
return null;
}
yMap.set(newKey, value);
}
clipboardData.children = yMap;
clipboardData.xywh = newXYWH;
} else if (clipboardData.type === MINDMAP) {
const yMap = new Y.Map();
const children = clipboardData.children ?? {};
for (const [oldKey, oldValue] of Object.entries(children)) {
const newKey = context.oldToNewIdMap.get(oldKey);
const newValue = {
...oldValue,
};
if (!newKey) {
console.error(
`Copy failed: cannot find the copied node in mind map, key: ${oldKey}`
);
return null;
}
if (oldValue.parent) {
const newParent = context.oldToNewIdMap.get(oldValue.parent);
if (!newParent) {
console.error(
`Copy failed: cannot find the copied node in mind map, parent: ${oldValue.parent}`
);
return null;
}
newValue.parent = newParent;
}
yMap.set(newKey, newValue);
}
clipboardData.children = yMap;
} else if (clipboardData.type === CONNECTOR) {
const source = clipboardData.source as Connection;
const target = clipboardData.target as Connection;
const oldBound = Bound.deserialize(clipboardData.xywh);
const newBound = Bound.deserialize(newXYWH);
const offset = Vec.sub([newBound.x, newBound.y], [oldBound.x, oldBound.y]);
if (source.id) {
source.id = context.oldToNewIdMap.get(source.id) ?? source.id;
} else if (source.position) {
source.position = Vec.add(source.position, offset);
}
if (target.id) {
target.id = context.oldToNewIdMap.get(target.id) ?? target.id;
} else if (target.position) {
target.position = Vec.add(target.position, offset);
}
} else {
clipboardData.xywh = newXYWH;
}
clipboardData.lockedBySelf = false;
const crud = std.get(EdgelessCRUDIdentifier);
const id = crud.addElement(
clipboardData.type as CanvasElementType,
clipboardData
);
if (!id) {
return null;
}
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'canvas:paste',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: clipboardData.type as string,
});
const element = crud.getElementById(id) as GfxPrimitiveElementModel;
if (!element) {
console.error(`Copy failed: cannot find the copied element, id: ${id}`);
return null;
}
return element;
}

View File

@@ -0,0 +1,701 @@
import { addAttachments } from '@blocksuite/affine-block-attachment';
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
import { addImages } from '@blocksuite/affine-block-image';
import {
EdgelessCRUDIdentifier,
ExportManager,
getSurfaceComponent,
} from '@blocksuite/affine-block-surface';
import { splitIntoLines } from '@blocksuite/affine-gfx-text';
import type { ShapeElementModel } from '@blocksuite/affine-model';
import {
BookmarkStyles,
DEFAULT_NOTE_HEIGHT,
DEFAULT_NOTE_WIDTH,
FrameBlockModel,
MAX_IMAGE_WIDTH,
} from '@blocksuite/affine-model';
import {
ClipboardAdapter,
decodeClipboardBlobs,
} from '@blocksuite/affine-shared/adapters';
import {
CANVAS_EXPORT_IGNORE_TAGS,
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
EmbedOptionProvider,
ParseDocUrlProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
isInsidePageEditor,
isTopLevelBlock,
isUrlInClipboard,
matchModels,
referenceToNode,
} from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import {
Bound,
getCommonBound,
type IBound,
type IVec,
Vec,
} from '@blocksuite/global/gfx';
import type {
EditorHost,
SurfaceSelection,
UIEventStateContext,
} from '@blocksuite/std';
import {
compareLayer,
type GfxBlockElementModel,
GfxControllerIdentifier,
type GfxPrimitiveElementModel,
type SerializedElement,
} from '@blocksuite/std/gfx';
import { type BlockSnapshot, type SliceSnapshot } from '@blocksuite/store';
import * as Y from 'yjs';
import { PageClipboard } from '../../clipboard/index.js';
import { getSortedCloneElements } from '../utils/clone-utils.js';
import { isCanvasElementWithText } from '../utils/query.js';
import { createElementsFromClipboardDataCommand } from './command.js';
import {
isPureFileInClipboard,
prepareClipboardData,
tryGetSvgFromClipboard,
} from './utils.js';
const BLOCKSUITE_SURFACE = 'blocksuite/surface';
const IMAGE_PADDING = 5; // for rotated shapes some padding is needed
interface CanvasExportOptions {
dpr?: number;
padding?: number;
background?: string;
}
export class EdgelessClipboardController extends PageClipboard {
static override key = 'affine-edgeless-clipboard';
private readonly _initEdgelessClipboard = () => {
this.std.event.add('copy', ctx => {
const { surfaceSelections, selectedIds } = this.selectionManager;
if (selectedIds.length === 0) return false;
this._onCopy(ctx, surfaceSelections).catch(console.error);
return;
});
this.std.event.add('paste', ctx => {
this._onPaste(ctx).catch(console.error);
});
this.std.event.add('cut', ctx => {
this._onCut(ctx).catch(console.error);
});
};
private readonly _onCopy = async (
_context: UIEventStateContext,
surfaceSelection: SurfaceSelection[]
) => {
const event = _context.get('clipboardState').raw;
event.preventDefault();
const elements = getSortedCloneElements(
this.selectionManager.selectedElements
);
// when note active, handle copy like page mode
if (surfaceSelection[0] && surfaceSelection[0].editing) {
// use build-in copy handler in rich-text when copy in surface text element
if (isCanvasElementWithText(elements[0])) return;
this.onPageCopy(_context);
return;
}
await this.std.clipboard.writeToClipboard(async _items => {
const data = await prepareClipboardData(elements, this.std);
return {
..._items,
[BLOCKSUITE_SURFACE]: JSON.stringify(data),
};
});
};
private readonly _onCut = async (_context: UIEventStateContext) => {
const { surfaceSelections, selectedElements } = this.selectionManager;
if (selectedElements.length === 0) return;
const event = _context.get('clipboardState').event;
event.preventDefault();
await this._onCopy(_context, surfaceSelections);
if (surfaceSelections[0]?.editing) {
// use build-in cut handler in rich-text when cut in surface text element
if (isCanvasElementWithText(selectedElements[0])) return;
this.onPageCut(_context);
return;
}
const elements = getSortedCloneElements(
this.selectionManager.selectedElements
);
this.doc.transact(() => {
this.crud.deleteElements(elements);
});
this.selectionManager.set({
editing: false,
elements: [],
});
};
private readonly _onPaste = async (_context: UIEventStateContext) => {
if (
document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement
) {
return;
}
const event = _context.get('clipboardState').raw;
event.preventDefault();
const { surfaceSelections, selectedElements } = this.selectionManager;
if (surfaceSelections[0]?.editing) {
// use build-in paste handler in rich-text when paste in surface text element
if (isCanvasElementWithText(selectedElements[0])) return;
this.onPagePaste(_context);
return;
}
const data = event.clipboardData;
if (!data) return;
if (!this.surface) return;
const lastMousePos = this.toolManager.lastMousePos$.peek();
const point: IVec = [lastMousePos.x, lastMousePos.y];
if (isPureFileInClipboard(data)) {
const files = data.files;
if (files.length === 0) return;
const imageFiles: File[] = [],
attachmentFiles: File[] = [];
[...files].forEach(file => {
if (file.type.startsWith('image/')) {
imageFiles.push(file);
} else {
attachmentFiles.push(file);
}
});
// when only images in clipboard, add image-blocks else add all files as attachments
if (attachmentFiles.length === 0) {
await addImages(this.std, imageFiles, {
point,
maxWidth: MAX_IMAGE_WIDTH,
});
} else {
await addAttachments(this.std, [...files], point);
}
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'canvas:paste',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: attachmentFiles.length === 0 ? 'image' : 'attachment',
});
return;
}
if (isUrlInClipboard(data)) {
const url = data.getData('text/plain');
const lastMousePos = this.toolManager.lastMousePos$.peek();
const [x, y] = this.gfx.viewport.toModelCoord(
lastMousePos.x,
lastMousePos.y
);
// try to interpret url as affine doc url
const parseDocUrlService = this.std.getOptional(ParseDocUrlProvider);
const docUrlInfo = parseDocUrlService?.parseDocUrl(url);
const options: Record<string, unknown> = {};
let flavour = 'affine:bookmark';
let style = BookmarkStyles[0];
let isInternalLink = false;
let isLinkedBlock = false;
if (docUrlInfo) {
const { docId: pageId, ...params } = docUrlInfo;
flavour = 'affine:embed-linked-doc';
style = 'vertical';
isInternalLink = true;
isLinkedBlock = referenceToNode({ pageId, params });
options.pageId = pageId;
if (params) options.params = params;
} else {
options.url = url;
const embedOptions = this.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
if (embedOptions) {
flavour = embedOptions.flavour;
style = embedOptions.styles[0];
}
}
const width = EMBED_CARD_WIDTH[style];
const height = EMBED_CARD_HEIGHT[style];
options.xywh = Bound.fromCenter(
Vec.toVec({
x,
y,
}),
width,
height
).serialize();
options.style = style;
const id = this.crud.addBlock(flavour, options, this.surface.model.id);
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'canvas:paste',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: flavour.split(':')[1],
});
this.std
.getOptional(TelemetryProvider)
?.track(isInternalLink ? 'LinkedDocCreated' : 'Link', {
page: 'whiteboard editor',
segment: 'whiteboard',
category: 'pasted link',
other: isInternalLink ? 'existing doc' : 'external link',
type: isInternalLink ? (isLinkedBlock ? 'block' : 'doc') : 'link',
});
this.selectionManager.set({
editing: false,
elements: [id],
});
return;
}
const svg = tryGetSvgFromClipboard(data);
if (svg) {
await addImages(this.std, [svg], { point, maxWidth: MAX_IMAGE_WIDTH });
return;
}
try {
// check for surface elements in clipboard
const json = this.std.clipboard.readFromClipboard(data);
const mayBeSurfaceDataJson = json[BLOCKSUITE_SURFACE];
if (mayBeSurfaceDataJson !== undefined) {
const elementsRawData = JSON.parse(mayBeSurfaceDataJson);
const { snapshot, blobs } = elementsRawData;
const job = this.std.store.getTransformer();
const map = job.assetsManager.getAssets();
decodeClipboardBlobs(blobs, map);
for (const blobId of map.keys()) {
await job.assetsManager.writeToBlob(blobId);
}
await this._pasteShapesAndBlocks(snapshot);
return;
}
// check for slice snapshot in clipboard
const mayBeSliceDataJson = json[ClipboardAdapter.MIME];
if (mayBeSliceDataJson === undefined) return;
const clipData = JSON.parse(mayBeSliceDataJson);
const sliceSnapShot = clipData?.snapshot as SliceSnapshot;
await this._pasteTextContentAsNote(sliceSnapShot.content);
} catch {
// if it is not parsable
await this._pasteTextContentAsNote(data.getData('text/plain'));
}
};
private get _exportManager() {
return this.std.getOptional(ExportManager);
}
private get doc() {
return this.std.store;
}
private get selectionManager() {
return this.gfx.selection;
}
private get surface() {
return getSurfaceComponent(this.std);
}
private get frame() {
return this.std.get(EdgelessFrameManagerIdentifier);
}
private get gfx() {
return this.std.get(GfxControllerIdentifier);
}
private get crud() {
return this.std.get(EdgelessCRUDIdentifier);
}
private get toolManager() {
return this.gfx.tool;
}
private _checkCanContinueToCanvas(
host: EditorHost,
pathName: string,
editorMode: boolean
) {
if (
location.pathname !== pathName ||
isInsidePageEditor(host) !== editorMode
) {
throw new Error('Unable to export content to canvas');
}
}
private async _edgelessToCanvas(
bound: IBound,
nodes?: GfxBlockElementModel[],
canvasElements: GfxPrimitiveElementModel[] = [],
{
background,
padding = IMAGE_PADDING,
dpr = window.devicePixelRatio || 1,
}: CanvasExportOptions = {}
): Promise<HTMLCanvasElement | undefined> {
const host = this.std.host;
const rootModel = this.doc.root;
if (!rootModel) return;
const html2canvas = (await import('html2canvas')).default;
if (!(html2canvas instanceof Function)) return;
if (!this.surface) return;
const pathname = location.pathname;
const editorMode = isInsidePageEditor(host);
const canvas = document.createElement('canvas');
canvas.width = (bound.w + padding * 2) * dpr;
canvas.height = (bound.h + padding * 2) * dpr;
const ctx = canvas.getContext('2d');
if (!ctx) return;
if (background) {
ctx.fillStyle = background;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.scale(dpr, dpr);
const replaceImgSrcWithSvg = this._exportManager?.replaceImgSrcWithSvg;
const replaceRichTextWithSvgElementFunc =
this._replaceRichTextWithSvgElement.bind(this);
const imageProxy = host.std.clipboard.configs.get('imageProxy');
const html2canvasOption = {
ignoreElements: function (element: Element) {
if (
CANVAS_EXPORT_IGNORE_TAGS.includes(element.tagName) ||
element.classList.contains('dg')
) {
return true;
} else {
return false;
}
},
onclone: async function (documentClone: Document, element: HTMLElement) {
// html2canvas can't support transform feature
element.style.setProperty('transform', 'none');
const layer = documentClone.querySelector('.affine-edgeless-layer');
if (layer && layer instanceof HTMLElement) {
layer.style.setProperty('transform', 'none');
}
const boxShadowElements = documentClone.querySelectorAll(
"[style*='box-shadow']"
);
boxShadowElements.forEach(function (element) {
if (element instanceof HTMLElement) {
element.style.setProperty('box-shadow', 'none');
}
});
await replaceImgSrcWithSvg?.(element);
replaceRichTextWithSvgElementFunc(element);
},
backgroundColor: 'transparent',
useCORS: imageProxy ? false : true,
proxy: imageProxy,
};
const _drawTopLevelBlock = async (
block: GfxBlockElementModel,
isInFrame = false
) => {
const blockComponent = this.std.view.getBlock(block.id);
if (!blockComponent) {
throw new BlockSuiteError(
ErrorCode.EdgelessExportError,
'Could not find edgeless block component.'
);
}
const blockBound = Bound.deserialize(block.xywh);
const canvasData = await html2canvas(
blockComponent as HTMLElement,
html2canvasOption
);
ctx.drawImage(
canvasData,
blockBound.x - bound.x + padding,
blockBound.y - bound.y + padding,
blockBound.w,
isInFrame
? (blockBound.w / canvasData.width) * canvasData.height
: blockBound.h
);
};
const nodeElements =
nodes ??
(this.gfx.getElementsByBound(bound, {
type: 'block',
}) as GfxBlockElementModel[]);
for (const nodeElement of nodeElements) {
await _drawTopLevelBlock(nodeElement);
if (matchModels(nodeElement, [FrameBlockModel])) {
const blocksInsideFrame: GfxBlockElementModel[] = [];
this.frame.getElementsInFrameBound(nodeElement, false).forEach(ele => {
if (isTopLevelBlock(ele)) {
blocksInsideFrame.push(ele as GfxBlockElementModel);
} else {
canvasElements.push(ele as GfxPrimitiveElementModel);
}
});
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < blocksInsideFrame.length; i++) {
const element = blocksInsideFrame[i];
await _drawTopLevelBlock(element, true);
}
}
this._checkCanContinueToCanvas(host, pathname, editorMode);
}
const surfaceCanvas = this.surface.renderer.getCanvasByBound(
bound,
canvasElements
);
ctx.drawImage(surfaceCanvas, padding, padding, bound.w, bound.h);
return canvas;
}
private _elementToSvgElement(
node: HTMLElement,
width: number,
height: number
) {
const xmlns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(xmlns, 'svg');
const foreignObject = document.createElementNS(xmlns, 'foreignObject');
svg.setAttribute('width', `${width}`);
svg.setAttribute('height', `${height}`);
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
foreignObject.setAttribute('width', '100%');
foreignObject.setAttribute('height', '100%');
foreignObject.setAttribute('x', '0');
foreignObject.setAttribute('y', '0');
foreignObject.setAttribute('externalResourcesRequired', 'true');
svg.append(foreignObject);
foreignObject.append(node);
return svg;
}
private _emitSelectionChangeAfterPaste(
canvasElementIds: string[],
blockIds: string[]
) {
const newSelected = [
...canvasElementIds,
...blockIds.filter(id => {
return isTopLevelBlock(this.doc.getModelById(id));
}),
];
this.selectionManager.set({
editing: false,
elements: newSelected,
});
}
private async _pasteShapesAndBlocks(
elementsRawData: (SerializedElement | BlockSnapshot)[]
) {
const [_, { createdElementsPromise }] = this.std.command.exec(
createElementsFromClipboardDataCommand,
{
elementsRawData,
}
);
if (!createdElementsPromise) return;
const { canvasElements, blockModels } = await createdElementsPromise;
this._emitSelectionChangeAfterPaste(
canvasElements.map(ele => ele.id),
blockModels.map(block => block.id)
);
}
private async _pasteTextContentAsNote(content: BlockSnapshot[] | string) {
const lastMousePos = this.toolManager.lastMousePos$.peek();
const [x, y] = this.gfx.viewport.toModelCoord(
lastMousePos.x,
lastMousePos.y
);
const noteProps = {
xywh: new Bound(
x,
y,
DEFAULT_NOTE_WIDTH,
DEFAULT_NOTE_HEIGHT
).serialize(),
};
const noteId = this.crud.addBlock(
'affine:note',
noteProps,
this.doc.root!.id
);
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'canvas:paste',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'note',
});
if (typeof content === 'string') {
splitIntoLines(content).forEach((line, idx) => {
this.crud.addBlock(
'affine:paragraph',
{ text: new Y.Text(line) },
noteId,
idx
);
});
} else {
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let index = 0; index < content.length; index++) {
const blockSnapshot = content[index];
if (blockSnapshot.flavour === 'affine:note') {
for (const child of blockSnapshot.children) {
await this.onBlockSnapshotPaste(child, this.doc, noteId);
}
continue;
}
await this.onBlockSnapshotPaste(content[index], this.doc, noteId);
}
}
this.gfx.selection.set({
elements: [noteId],
editing: false,
});
this.gfx.tool.setTool('default');
}
private _replaceRichTextWithSvgElement(element: HTMLElement) {
const richList = Array.from(element.querySelectorAll('.inline-editor'));
richList.forEach(rich => {
const svgEle = this._elementToSvgElement(
rich.cloneNode(true) as HTMLElement,
rich.clientWidth,
rich.clientHeight + 1
);
rich.parentElement?.append(svgEle);
rich.remove();
});
}
copy() {
document.dispatchEvent(
new Event('copy', {
bubbles: true,
cancelable: true,
})
);
}
override mounted() {
if (!navigator.clipboard) {
console.error(
'navigator.clipboard is not supported in current environment.'
);
return;
}
if (this._disposables.disposed) {
this._disposables = new DisposableGroup();
}
this._init();
this._initEdgelessClipboard();
}
async toCanvas(
blocks: GfxBlockElementModel[],
shapes: ShapeElementModel[],
options?: CanvasExportOptions
) {
blocks.sort(compareLayer);
shapes.sort(compareLayer);
const bounds: IBound[] = [];
blocks.forEach(block => {
bounds.push(Bound.deserialize(block.xywh));
});
shapes.forEach(shape => {
bounds.push(shape.elementBound);
});
const bound = getCommonBound(bounds);
if (!bound) {
console.error('bound not exist');
return;
}
const canvas = await this._edgelessToCanvas(bound, blocks, shapes, options);
return canvas;
}
}

View File

@@ -0,0 +1,220 @@
import {
type ClipboardConfigCreationContext,
EdgelessClipboardConfigIdentifier,
EdgelessCRUDIdentifier,
SurfaceGroupLikeModel,
} from '@blocksuite/affine-block-surface';
import { Bound, type IVec, type SerializedXYWH } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import type { BlockStdScope, Command } from '@blocksuite/std';
import {
type GfxBlockElementModel,
type GfxCompatibleProps,
GfxControllerIdentifier,
type GfxModel,
type GfxPrimitiveElementModel,
type SerializedElement,
SortOrder,
} from '@blocksuite/std/gfx';
import { type BlockSnapshot, BlockSnapshotSchema } from '@blocksuite/store';
import { createCanvasElement } from './canvas';
import {
createNewPresentationIndexes,
edgelessElementsBoundFromRawData,
} from './utils';
interface Input {
elementsRawData: (SerializedElement | BlockSnapshot)[];
pasteCenter?: IVec;
}
type CreatedElements = {
canvasElements: GfxPrimitiveElementModel[];
blockModels: GfxBlockElementModel[];
};
interface Output {
createdElementsPromise: Promise<CreatedElements>;
}
export const createElementsFromClipboardDataCommand: Command<Input, Output> = (
ctx,
next
) => {
const { std, elementsRawData } = ctx;
let { pasteCenter } = ctx;
const gfx = std.get(GfxControllerIdentifier);
const toolManager = gfx.tool;
const runner = async (): Promise<CreatedElements> => {
let oldCommonBound, pasteX, pasteY;
{
const lastMousePos = toolManager.lastMousePos$.peek();
pasteCenter =
pasteCenter ??
gfx.viewport.toModelCoord(lastMousePos.x, lastMousePos.y);
const [modelX, modelY] = pasteCenter;
oldCommonBound = edgelessElementsBoundFromRawData(elementsRawData);
pasteX = modelX - oldCommonBound.w / 2;
pasteY = modelY - oldCommonBound.h / 2;
}
const getNewXYWH = (oldXYWH: SerializedXYWH) => {
const oldBound = Bound.deserialize(oldXYWH);
return new Bound(
oldBound.x + pasteX - oldCommonBound.x,
oldBound.y + pasteY - oldCommonBound.y,
oldBound.w,
oldBound.h
).serialize();
};
// create blocks and canvas elements
const context: ClipboardConfigCreationContext = {
oldToNewIdMap: new Map<string, string>(),
originalIndexes: new Map<string, string>(),
newPresentationIndexes: createNewPresentationIndexes(
elementsRawData,
std
),
};
const blockModels: GfxBlockElementModel[] = [];
const canvasElements: GfxPrimitiveElementModel[] = [];
const allElements: GfxModel[] = [];
for (const data of elementsRawData) {
const { data: blockSnapshot } = BlockSnapshotSchema.safeParse(data);
if (blockSnapshot) {
const oldId = blockSnapshot.id;
const config = std.getOptional(
EdgelessClipboardConfigIdentifier(blockSnapshot.flavour)
);
if (!config) continue;
if (typeof blockSnapshot.props.index !== 'string') {
console.error(`Block(id: ${oldId}) does not have index property`);
continue;
}
const originalIndex = (blockSnapshot.props as GfxCompatibleProps).index;
if (typeof blockSnapshot.props.xywh !== 'string') {
console.error(`Block(id: ${oldId}) does not have xywh property`);
continue;
}
assertType<GfxCompatibleProps>(blockSnapshot.props);
blockSnapshot.props.xywh = getNewXYWH(
blockSnapshot.props.xywh as SerializedXYWH
);
blockSnapshot.props.lockedBySelf = false;
const newId = await config.createBlock(blockSnapshot, context);
if (!newId) continue;
const block = std.store.getBlock(newId);
if (!block) continue;
assertType<GfxBlockElementModel>(block.model);
blockModels.push(block.model);
allElements.push(block.model);
context.oldToNewIdMap.set(oldId, newId);
context.originalIndexes.set(oldId, originalIndex);
} else {
assertType<SerializedElement>(data);
const oldId = data.id;
const element = createCanvasElement(
std,
data,
context,
getNewXYWH(data.xywh)
);
if (!element) continue;
canvasElements.push(element);
allElements.push(element);
context.oldToNewIdMap.set(oldId, element.id);
context.originalIndexes.set(oldId, element.index);
}
}
// remap old id to new id for the original index
const oldIds = [...context.originalIndexes.keys()];
oldIds.forEach(oldId => {
const newId = context.oldToNewIdMap.get(oldId);
const originalIndex = context.originalIndexes.get(oldId);
if (newId && originalIndex) {
context.originalIndexes.set(newId, originalIndex);
context.originalIndexes.delete(oldId);
}
});
updatePastedElementsIndex(std, allElements, context.originalIndexes);
return {
canvasElements: canvasElements,
blockModels: blockModels,
};
};
return next({
createdElementsPromise: runner(),
});
};
function updatePastedElementsIndex(
std: BlockStdScope,
elements: GfxModel[],
originalIndexes: Map<string, string>
) {
const gfx = std.get(GfxControllerIdentifier);
const crud = std.get(EdgelessCRUDIdentifier);
function compare(a: GfxModel, b: GfxModel) {
if (a instanceof SurfaceGroupLikeModel && a.hasDescendant(b)) {
return SortOrder.BEFORE;
} else if (b instanceof SurfaceGroupLikeModel && b.hasDescendant(a)) {
return SortOrder.AFTER;
} else {
const aGroups = a.groups as SurfaceGroupLikeModel[];
const bGroups = b.groups as SurfaceGroupLikeModel[];
let i = 1;
let aGroup: GfxModel | undefined = aGroups.at(-i);
let bGroup: GfxModel | undefined = bGroups.at(-i);
while (aGroup === bGroup && aGroup) {
++i;
aGroup = aGroups.at(-i);
bGroup = bGroups.at(-i);
}
aGroup = aGroup ?? a;
bGroup = bGroup ?? b;
return originalIndexes.get(aGroup.id) === originalIndexes.get(bGroup.id)
? SortOrder.SAME
: originalIndexes.get(aGroup.id)! < originalIndexes.get(bGroup.id)!
? SortOrder.BEFORE
: SortOrder.AFTER;
}
}
const idxGenerator = gfx.layer.createIndexGenerator();
const sortedElements = elements.sort(compare);
sortedElements.forEach(ele => {
const newIndex = idxGenerator();
crud.updateElement(ele.id, {
index: newIndex,
});
});
}

View File

@@ -0,0 +1,138 @@
import {
EdgelessFrameManager,
EdgelessFrameManagerIdentifier,
} from '@blocksuite/affine-block-frame';
import type { FrameBlockProps } from '@blocksuite/affine-model';
import { encodeClipboardBlobs } from '@blocksuite/affine-shared/adapters';
import { Bound, getBoundWithRotation } from '@blocksuite/global/gfx';
import type { BlockStdScope } from '@blocksuite/std';
import {
generateKeyBetweenV2,
type GfxModel,
type SerializedElement,
} from '@blocksuite/std/gfx';
import { type BlockSnapshot, BlockSnapshotSchema } from '@blocksuite/store';
import DOMPurify from 'dompurify';
import { serializeElement } from '../utils/clone-utils';
import { isAttachmentBlock, isImageBlock } from '../utils/query';
type FrameSnapshot = BlockSnapshot & {
props: FrameBlockProps;
};
export function createNewPresentationIndexes(
raw: (SerializedElement | BlockSnapshot)[],
std: BlockStdScope
) {
const frames = raw
.filter((block): block is FrameSnapshot => {
const { data } = BlockSnapshotSchema.safeParse(block);
return data?.flavour === 'affine:frame';
})
.sort((a, b) => EdgelessFrameManager.framePresentationComparator(a, b));
const frameMgr = std.get(EdgelessFrameManagerIdentifier);
let before = frameMgr.generatePresentationIndex();
const result = new Map<string, string>();
frames.forEach(frame => {
result.set(frame.id, before);
before = generateKeyBetweenV2(before, null);
});
return result;
}
export async function prepareClipboardData(
selectedAll: GfxModel[],
std: BlockStdScope
) {
const job = std.store.getTransformer();
const selected = await Promise.all(
selectedAll.map(async selected => {
const data = serializeElement(selected, selectedAll, job);
if (!data) {
return;
}
if (isAttachmentBlock(selected) || isImageBlock(selected)) {
await job.assetsManager.readFromBlob(data.props.sourceId as string);
}
return data;
})
);
const blobs = await encodeClipboardBlobs(job.assetsManager.getAssets());
return {
snapshot: selected.filter(d => !!d),
blobs,
};
}
export function isPureFileInClipboard(clipboardData: DataTransfer) {
const types = clipboardData.types;
return (
(types.length === 1 && types[0] === 'Files') ||
(types.length === 2 &&
(types.includes('text/plain') || types.includes('text/html')) &&
types.includes('Files'))
);
}
export function tryGetSvgFromClipboard(clipboardData: DataTransfer) {
const types = clipboardData.types;
if (types.length === 1 && types[0] !== 'text/plain') {
return null;
}
const parser = new DOMParser();
const svgDoc = parser.parseFromString(
clipboardData.getData('text/plain'),
'image/svg+xml'
);
const svg = svgDoc.documentElement;
if (svg.tagName !== 'svg' || !svg.hasAttribute('xmlns')) {
return null;
}
const svgContent = DOMPurify.sanitize(svgDoc.documentElement, {
USE_PROFILES: { svg: true },
});
const blob = new Blob([svgContent], { type: 'image/svg+xml' });
const file = new File([blob], 'pasted-image.svg', { type: 'image/svg+xml' });
return file;
}
export function edgelessElementsBoundFromRawData(
elementsRawData: (SerializedElement | BlockSnapshot)[]
) {
if (elementsRawData.length === 0) return new Bound();
let prev: Bound | null = null;
for (const data of elementsRawData) {
const { data: blockSnapshot } = BlockSnapshotSchema.safeParse(data);
const bound = blockSnapshot
? getBoundFromGfxBlockSnapshot(blockSnapshot)
: getBoundFromSerializedElement(data as SerializedElement);
if (!bound) continue;
if (!prev) prev = bound;
else prev = prev.unite(bound);
}
return prev ?? new Bound();
}
function getBoundFromSerializedElement(element: SerializedElement) {
return Bound.from(
getBoundWithRotation({
...Bound.deserialize(element.xywh),
rotate: typeof element.rotate === 'number' ? element.rotate : 0,
})
);
}
function getBoundFromGfxBlockSnapshot(snapshot: BlockSnapshot) {
if (typeof snapshot.props.xywh !== 'string') return null;
return Bound.deserialize(snapshot.props.xywh);
}

View File

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

View File

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

View File

@@ -0,0 +1,350 @@
import {
EdgelessCRUDIdentifier,
type Options,
Overlay,
type RoughCanvas,
} from '@blocksuite/affine-block-surface';
import { type Shape, ShapeFactory } from '@blocksuite/affine-gfx-shape';
import {
type Connection,
getShapeRadius,
getShapeType,
GroupElementModel,
type NoteBlockModel,
ShapeElementModel,
type ShapeName,
type ShapeStyle,
} from '@blocksuite/affine-model';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound, normalizeDegAngle, type XYWH } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import type { BlockComponent } from '@blocksuite/std';
import type { GfxController, GfxModel } from '@blocksuite/std/gfx';
import * as Y from 'yjs';
export enum Direction {
Right,
Bottom,
Left,
Top,
}
export const PANEL_WIDTH = 136;
export const PANEL_HEIGHT = 108;
export const MAIN_GAP = 100;
export const SECOND_GAP = 20;
export const DEFAULT_NOTE_OVERLAY_HEIGHT = 110;
export const DEFAULT_TEXT_WIDTH = 116;
export const DEFAULT_TEXT_HEIGHT = 24;
export type TARGET_SHAPE_TYPE = ShapeName;
export type AUTO_COMPLETE_TARGET_TYPE =
| TARGET_SHAPE_TYPE
| 'text'
| 'note'
| 'frame';
class AutoCompleteTargetOverlay extends Overlay {
xywh: XYWH;
constructor(gfx: GfxController, xywh: XYWH) {
super(gfx);
this.xywh = xywh;
}
override render(_ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {}
}
export class AutoCompleteTextOverlay extends AutoCompleteTargetOverlay {
constructor(gfx: GfxController, xywh: XYWH) {
super(gfx, xywh);
}
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
const [x, y, w, h] = this.xywh;
ctx.globalAlpha = 0.4;
ctx.strokeStyle = '#1e96eb';
ctx.lineWidth = 1;
ctx.strokeRect(x, y, w, h);
// fill text placeholder
ctx.font = '15px sans-serif';
ctx.fillStyle = '#C0BFC1';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText("Type '/' to insert", x + w / 2, y + h / 2);
}
}
export class AutoCompleteNoteOverlay extends AutoCompleteTargetOverlay {
private readonly _background: string;
constructor(gfx: GfxController, xywh: XYWH, background: string) {
super(gfx, xywh);
this._background = background;
}
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
const [x, y, w, h] = this.xywh;
ctx.globalAlpha = 0.4;
ctx.fillStyle = this._background;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.10)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(x, y, w, h, 8);
ctx.closePath();
ctx.fill();
ctx.stroke();
// fill text placeholder
ctx.font = '15px sans-serif';
ctx.fillStyle = 'black';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText("Type '/' for command", x + 24, y + h / 2);
}
}
export class AutoCompleteFrameOverlay extends AutoCompleteTargetOverlay {
private readonly _strokeColor;
constructor(gfx: GfxController, xywh: XYWH, strokeColor: string) {
super(gfx, xywh);
this._strokeColor = strokeColor;
}
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
const [x, y, w, h] = this.xywh;
// frame title background
const titleWidth = 72;
const titleHeight = 30;
const titleY = y - titleHeight - 10;
ctx.globalAlpha = 0.4;
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.beginPath();
ctx.roundRect(x, titleY, titleWidth, titleHeight, 4);
ctx.closePath();
ctx.fill();
// fill title text
ctx.globalAlpha = 1;
ctx.font = '14px sans-serif';
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Frame', x + titleWidth / 2, titleY + titleHeight / 2);
// frame stroke
ctx.globalAlpha = 0.4;
ctx.strokeStyle = this._strokeColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(x, y, w, h, 8);
ctx.closePath();
ctx.stroke();
}
}
export class AutoCompleteShapeOverlay extends Overlay {
private readonly _shape: Shape;
constructor(
gfx: GfxController,
xywh: XYWH,
type: TARGET_SHAPE_TYPE,
options: Options,
shapeStyle: ShapeStyle
) {
super(gfx);
this._shape = ShapeFactory.createShape(xywh, type, options, shapeStyle);
}
override render(ctx: CanvasRenderingContext2D, rc: RoughCanvas) {
ctx.globalAlpha = 0.4;
this._shape.draw(ctx, rc);
}
}
export function nextBound(
type: Direction,
curShape: ShapeElementModel,
elements: ShapeElementModel[]
) {
const bound = Bound.deserialize(curShape.xywh);
const { x, y, w, h } = bound;
let nextBound: Bound;
let angle = 0;
switch (type) {
case Direction.Right:
angle = 0;
break;
case Direction.Bottom:
angle = 90;
break;
case Direction.Left:
angle = 180;
break;
case Direction.Top:
angle = 270;
break;
}
angle = normalizeDegAngle(angle + curShape.rotate);
if (angle >= 45 && angle <= 135) {
nextBound = new Bound(x, y + h + MAIN_GAP, w, h);
} else if (angle >= 135 && angle <= 225) {
nextBound = new Bound(x - w - MAIN_GAP, y, w, h);
} else if (angle >= 225 && angle <= 315) {
nextBound = new Bound(x, y - h - MAIN_GAP, w, h);
} else {
nextBound = new Bound(x + w + MAIN_GAP, y, w, h);
}
function isValidBound(bound: Bound) {
return !elements.some(a => bound.isOverlapWithBound(a.elementBound));
}
let count = 0;
function findValidBound() {
count++;
const number = Math.ceil(count / 2);
const next = nextBound.clone();
switch (type) {
case Direction.Right:
case Direction.Left:
next.y =
count % 2 === 1
? nextBound.y - (h + SECOND_GAP) * number
: nextBound.y + (h + SECOND_GAP) * number;
break;
case Direction.Bottom:
case Direction.Top:
next.x =
count % 2 === 1
? nextBound.x - (w + SECOND_GAP) * number
: nextBound.x + (w + SECOND_GAP) * number;
break;
}
if (isValidBound(next)) return next;
return findValidBound();
}
return isValidBound(nextBound) ? nextBound : findValidBound();
}
export function getPosition(type: Direction) {
let startPosition: Connection['position'];
let endPosition: Connection['position'];
switch (type) {
case Direction.Right:
startPosition = [1, 0.5];
endPosition = [0, 0.5];
break;
case Direction.Bottom:
startPosition = [0.5, 1];
endPosition = [0.5, 0];
break;
case Direction.Left:
startPosition = [0, 0.5];
endPosition = [1, 0.5];
break;
case Direction.Top:
startPosition = [0.5, 0];
endPosition = [0.5, 1];
break;
}
return { startPosition, endPosition };
}
export function isShape(element: unknown): element is ShapeElementModel {
return element instanceof ShapeElementModel;
}
export function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function createEdgelessElement(
edgeless: BlockComponent,
current: ShapeElementModel | NoteBlockModel,
bound: Bound
) {
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
let id;
let element: GfxModel | null = null;
if (isShape(current)) {
id = crud.addElement(current.type, {
...current.serialize(),
text: new Y.Text(),
xywh: bound.serialize(),
});
if (!id) return null;
element = crud.getElementById(id);
} else {
const { doc } = edgeless;
id = doc.addBlock(
'affine:note',
{
background: current.props.background,
displayMode: current.props.displayMode,
edgeless: current.props.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.props.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: BlockComponent,
current: ShapeElementModel | NoteBlockModel,
targetType: TARGET_SHAPE_TYPE
) {
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
const id = crud.addElement('shape', {
shapeType: getShapeType(targetType),
radius: getShapeRadius(targetType),
text: new Y.Text(),
});
if (!id) return null;
const element = crud.getElementById(id);
const group = current.group;
if (group instanceof GroupElementModel && element) {
group.addChild(element);
}
return id;
}

View File

@@ -0,0 +1,441 @@
import type { NoteBlockComponent } from '@blocksuite/affine-block-note';
import {
EdgelessLegacySlotIdentifier,
getSurfaceComponent,
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 { DisposableGroup } from '@blocksuite/global/disposable';
import { deserializeXYWH, Point, serializeXYWH } from '@blocksuite/global/gfx';
import { ScissorsIcon } from '@blocksuite/icons/lit';
import { WidgetComponent } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
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 { EdgelessSelectedRectWidget } from '../rects/edgeless-selected-rect';
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> {
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.host.view.getWidget(
'edgeless-selected-rect',
this.host.id
) as EdgelessSelectedRectWidget | null;
}
private _sliceNote() {
if (!this._anchorNote || !this._noteBlockIds.length) return;
const doc = this.doc;
const {
index: originIndex,
xywh,
background,
displayMode,
} = this._anchorNote.props;
const { children } = this._anchorNote;
const {
collapse: _,
collapsedHeight: __,
...restOfEdgeless
} = this._anchorNote.props.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.getModelById(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, gfx } = this;
this._updateDivingLineAndBlockIds();
const slots = std.get(EdgelessLegacySlotIdentifier);
disposables.add(
slots.elementResizeStart.subscribe(() => {
this._isResizing = true;
})
);
disposables.add(
slots.elementResizeEnd.subscribe(() => {
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.subscribe(() => {
this._hidden = true;
this.requestUpdate();
})
);
disposables.add(
gfx.selection.slots.updated.subscribe(() => {
this._enableNoteSlicer = false;
this._updateSlicedNote();
if (this.selectedRectEle) {
this.selectedRectEle.autoCompleteOff = false;
}
})
);
disposables.add(
slots.toggleNoteSlicer.subscribe(() => {
this._enableNoteSlicer = !this._enableNoteSlicer;
if (this.selectedRectEle && this._enableNoteSlicer) {
this.selectedRectEle.autoCompleteOff = true;
}
})
);
requestAnimationFrame(() => {
const surface = getSurfaceComponent(std);
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.subscribe(({ 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;
}

View File

@@ -0,0 +1,61 @@
import type { RootBlockModel } from '@blocksuite/affine-model';
import { WidgetComponent } from '@blocksuite/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() {
if (!this.block) {
return nothing;
}
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>
`;
}
}

View File

@@ -0,0 +1,219 @@
import type { IVec } from '@blocksuite/global/gfx';
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;
}
}
}

View File

@@ -0,0 +1,705 @@
import { NOTE_MIN_WIDTH } from '@blocksuite/affine-model';
import {
Bound,
getQuadBoundWithRotation,
type IPoint,
type IVec,
type PointLocation,
rotatePoints,
} from '@blocksuite/global/gfx';
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();
if (!rect) {
return;
}
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,
_originalRect,
_currentRect,
} = this;
proportion ||= this._proportion;
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);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,103 @@
import { QuickToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
import { HandIcon, SelectIcon } from '@blocksuite/icons/lit';
import type { GfxToolsFullOptionValue } from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { query } from 'lit/decorators.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.gfx.tool.currentToolName$.value;
if (tool === 'default' || tool === 'pan') {
localStorage.defaultTool = tool;
}
})
);
}
override render() {
const type = this.edgelessTool?.type;
const { active } = this;
const tipInfo =
type === 'pan'
? { tip: 'Hand', shortcut: 'H' }
: { tip: 'Select', shortcut: 'V' };
return html`
<edgeless-tool-icon-button
class="edgeless-default-button ${type}"
.tooltip=${html`<affine-tooltip-content-with-shortcut
data-tip="${tipInfo.tip}"
data-shortcut="${tipInfo.shortcut}"
></affine-tooltip-content-with-shortcut>`}
.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;
}

View File

@@ -0,0 +1,32 @@
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 '@blocksuite/affine-widget-edgeless-toolbar';
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);
},
});

View File

@@ -0,0 +1,65 @@
import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmark';
import { LinkIcon } from '@blocksuite/affine-components/icons';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import { QuickToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
import { css, html, LitElement } from 'lit';
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="${html`<affine-tooltip-content-with-shortcut
data-tip="${'Link'}"
data-shortcut="${'@'}"
></affine-tooltip-content-with-shortcut>`}"
.tooltipOffset=${17}
class="edgeless-link-tool-button"
@click=${this._onClick}
>
<span class="link-icon">${LinkIcon}</span>
</edgeless-tool-icon-button>`;
}
}

View File

@@ -0,0 +1,44 @@
import { frameQuickTool } from '@blocksuite/affine-block-frame';
import { penSeniorTool } from '@blocksuite/affine-gfx-brush';
import { connectorQuickTool } from '@blocksuite/affine-gfx-connector';
import { mindMapSeniorTool } from '@blocksuite/affine-gfx-mindmap';
import { noteSeniorTool } from '@blocksuite/affine-gfx-note';
import { shapeSeniorTool } from '@blocksuite/affine-gfx-shape';
import { templateSeniorTool } from '@blocksuite/affine-gfx-template';
import { QuickToolExtension } from '@blocksuite/affine-widget-edgeless-toolbar';
import { html } from 'lit';
import { buildLinkDenseMenu } from './link/link-dense-menu.js';
const defaultQuickTool = QuickToolExtension('default', ({ block }) => {
return {
type: 'default',
content: html`<edgeless-default-tool-button
.edgeless=${block}
></edgeless-default-tool-button>`,
};
});
const linkQuickTool = QuickToolExtension('link', ({ block, gfx }) => {
return {
content: html`<edgeless-link-tool-button
.edgeless=${block}
></edgeless-link-tool-button>`,
menu: buildLinkDenseMenu(block, gfx),
};
});
export const quickTools = [
defaultQuickTool,
frameQuickTool,
connectorQuickTool,
linkQuickTool,
];
export const seniorTools = [
noteSeniorTool,
penSeniorTool,
shapeSeniorTool,
mindMapSeniorTool,
templateSeniorTool,
];

View File

@@ -0,0 +1,143 @@
import type { IVec } from '@blocksuite/global/gfx';
import { normalizeDegAngle, Vec } from '@blocksuite/global/gfx';
import type { CursorType, StandardCursor } from '@blocksuite/std/gfx';
export function generateCursorUrl(
angle = 0,
fallback: StandardCursor = 'default'
): CursorType {
return `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg transform='rotate(${angle} 16 16)'%3E%3Cpath fill='white' d='M13.7,18.5h3.9l0-1.5c0-1.4-1.2-2.6-2.6-2.6h-1.5v3.9l-5.8-5.8l5.8-5.8v3.9h2.3c3.1,0,5.6,2.5,5.6,5.6v2.3h3.9l-5.8,5.8L13.7,18.5z'/%3E%3Cpath d='M20.4,19.4v-3.2c0-2.6-2.1-4.7-4.7-4.7h-3.2l0,0V9L9,12.6l3.6,3.6v-2.6l0,0H15c1.9,0,3.5,1.6,3.5,3.5v2.4l0,0h-2.6l3.6,3.6l3.6-3.6L20.4,19.4L20.4,19.4z'/%3E%3C/g%3E%3C/svg%3E") 16 16, ${fallback}`;
}
export function getCommonRectStyle(
rect: DOMRect,
active = false,
selected = false,
rotate = 0
) {
return {
'--affine-border-width': `${active ? 2 : 1}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
transform: `translate(${rect.x}px, ${rect.y}px) rotate(${rotate}deg)`,
backgroundColor: !active && selected ? 'var(--affine-hover-color)' : '',
};
}
const RESIZE_CURSORS: CursorType[] = [
'ew-resize',
'nwse-resize',
'ns-resize',
'nesw-resize',
];
export function rotateResizeCursor(angle: number): StandardCursor {
const a = Math.round(angle / (Math.PI / 4));
const cursor = RESIZE_CURSORS[a % RESIZE_CURSORS.length];
return cursor as StandardCursor;
}
export function calcAngle(target: HTMLElement, point: IVec, offset = 0) {
const rect = target
.closest('.affine-edgeless-selected-rect')
?.getBoundingClientRect();
if (!rect) {
console.error('rect not found when calc angle');
return 0;
}
const { left, top, right, bottom } = rect;
const center = Vec.med([left, top], [right, bottom]);
return normalizeDegAngle(
((Vec.angle(center, point) + offset) * 180) / Math.PI
);
}
export function calcAngleWithRotation(
target: HTMLElement,
point: IVec,
rect: DOMRect,
rotate: number
) {
const handle = target.parentElement;
const ariaLabel = handle?.getAttribute('aria-label');
const { left, top, right, bottom, width, height } = rect;
const size = Math.min(width, height);
const sx = size / width;
const sy = size / height;
const center = Vec.med([left, top], [right, bottom]);
const draggingPoint = [0, 0];
switch (ariaLabel) {
case 'top-left': {
draggingPoint[0] = left;
draggingPoint[1] = top;
break;
}
case 'top-right': {
draggingPoint[0] = right;
draggingPoint[1] = top;
break;
}
case 'bottom-right': {
draggingPoint[0] = right;
draggingPoint[1] = bottom;
break;
}
case 'bottom-left': {
draggingPoint[0] = left;
draggingPoint[1] = bottom;
break;
}
}
const dp = new DOMMatrix()
.translateSelf(center[0], center[1])
.rotateSelf(rotate)
.translateSelf(-center[0], -center[1])
.transformPoint(new DOMPoint(...draggingPoint));
const m = new DOMMatrix()
.translateSelf(dp.x, dp.y)
.rotateSelf(rotate)
.translateSelf(-dp.x, -dp.y)
.scaleSelf(sx, sy, 1, dp.x, dp.y, 0)
.translateSelf(dp.x, dp.y)
.rotateSelf(-rotate)
.translateSelf(-dp.x, -dp.y);
const c = new DOMPoint(...center).matrixTransform(m);
return normalizeDegAngle((Vec.angle([c.x, c.y], point) * 180) / Math.PI);
}
export function calcAngleEdgeWithRotation(target: HTMLElement, rotate: number) {
let angleWithEdge = 0;
const handle = target.parentElement;
const ariaLabel = handle?.getAttribute('aria-label');
switch (ariaLabel) {
case 'top': {
angleWithEdge = 270;
break;
}
case 'bottom': {
angleWithEdge = 90;
break;
}
case 'left': {
angleWithEdge = 180;
break;
}
case 'right': {
angleWithEdge = 0;
break;
}
}
return angleWithEdge + rotate;
}
export function getResizeLabel(target: HTMLElement) {
const handle = target.parentElement;
const ariaLabel = handle?.getAttribute('aria-label');
return ariaLabel;
}

View File

@@ -0,0 +1,293 @@
import {
autoArrangeElementsCommand,
autoResizeElementsCommand,
EdgelessCRUDIdentifier,
updateXYWH,
} from '@blocksuite/affine-block-surface';
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
import type { ToolbarContext } from '@blocksuite/affine-shared/services';
import type {
Menu,
MenuItem,
} from '@blocksuite/affine-widget-edgeless-toolbar';
import { renderMenuItems } from '@blocksuite/affine-widget-edgeless-toolbar';
import { Bound } from '@blocksuite/global/gfx';
import {
AlignBottomIcon,
AlignHorizontalCenterIcon,
AlignLeftIcon,
AlignRightIcon,
AlignTopIcon,
AlignVerticalCenterIcon,
AutoTidyUpIcon,
DistributeHorizontalIcon,
DistributeVerticalIcon,
ResizeTidyUpIcon,
} from '@blocksuite/icons/lit';
import type { GfxModel } from '@blocksuite/std/gfx';
import { html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
enum Alignment {
None,
AutoArrange,
AutoResize,
Bottom,
DistributeHorizontally,
DistributeVertically,
Horizontally,
Left,
Right,
Top,
Vertically,
}
type AlignmentMap = Record<
Alignment,
(ctx: ToolbarContext, elements: GfxModel[]) => void
>;
const HORIZONTAL_ALIGNMENT = [
{
key: 'Align left',
value: Alignment.Left,
icon: AlignLeftIcon(),
},
{
key: 'Align horizontally',
value: Alignment.Horizontally,
icon: AlignHorizontalCenterIcon(),
},
{
key: 'Align right',
value: Alignment.Right,
icon: AlignRightIcon(),
},
{
key: 'Distribute horizontally',
value: Alignment.DistributeHorizontally,
icon: DistributeHorizontalIcon(),
},
] as const satisfies MenuItem<Alignment>[];
const VERTICAL_ALIGNMENT = [
{
key: 'Align top',
value: Alignment.Top,
icon: AlignTopIcon(),
},
{
key: 'Align vertically',
value: Alignment.Vertically,
icon: AlignVerticalCenterIcon(),
},
{
key: 'Align bottom',
value: Alignment.Bottom,
icon: AlignBottomIcon(),
},
{
key: 'Distribute vertically',
value: Alignment.DistributeVertically,
icon: DistributeVerticalIcon(),
},
] as const satisfies MenuItem<Alignment>[];
const AUTO_ALIGNMENT = [
{
key: 'Auto arrange',
value: Alignment.AutoArrange,
icon: AutoTidyUpIcon(),
},
{
key: 'Resize & Align',
value: Alignment.AutoResize,
icon: ResizeTidyUpIcon(),
},
] as const satisfies MenuItem<Alignment>[];
const alignment = {
// None: do nothing
[Alignment.None]() {},
// Horizontal
[Alignment.Left](ctx: ToolbarContext, elements: GfxModel[]) {
const bounds = elements.map(a => a.elementBound);
const left = Math.min(...bounds.map(b => b.minX));
for (const [index, element] of elements.entries()) {
const elementBound = bounds[index];
const bound = Bound.deserialize(element.xywh);
const offset = bound.minX - elementBound.minX;
bound.x = left + offset;
updateXYWHWith(ctx, element, bound);
}
},
[Alignment.Horizontally](ctx: ToolbarContext, elements: GfxModel[]) {
const bounds = elements.map(a => a.elementBound);
const left = Math.min(...bounds.map(b => b.minX));
const right = Math.max(...bounds.map(b => b.maxX));
const centerX = (left + right) / 2;
for (const element of elements) {
const bound = Bound.deserialize(element.xywh);
bound.x = centerX - bound.w / 2;
updateXYWHWith(ctx, element, bound);
}
},
[Alignment.Right](ctx: ToolbarContext, elements: GfxModel[]) {
const bounds = elements.map(a => a.elementBound);
const right = Math.max(...bounds.map(b => b.maxX));
for (const [i, element] of elements.entries()) {
const elementBound = bounds[i];
const bound = Bound.deserialize(element.xywh);
const offset = bound.maxX - elementBound.maxX;
bound.x = right - bound.w + offset;
updateXYWHWith(ctx, element, bound);
}
},
[Alignment.DistributeHorizontally](
ctx: ToolbarContext,
elements: GfxModel[]
) {
elements.sort((a, b) => a.elementBound.minX - b.elementBound.minX);
const bounds = elements.map(a => a.elementBound);
const left = bounds[0].minX;
const right = bounds[bounds.length - 1].maxX;
const totalWidth = right - left;
const totalGap =
totalWidth - elements.reduce((prev, ele) => prev + ele.elementBound.w, 0);
const gap = totalGap / (elements.length - 1);
let next = bounds[0].maxX + gap;
for (let i = 1; i < elements.length - 1; i++) {
const bound = Bound.deserialize(elements[i].xywh);
bound.x = next + bounds[i].w / 2 - bound.w / 2;
next += gap + bounds[i].w;
updateXYWHWith(ctx, elements[i], bound);
}
},
// Vertical
[Alignment.Top](ctx: ToolbarContext, elements: GfxModel[]) {
const bounds = elements.map(a => a.elementBound);
const top = Math.min(...bounds.map(b => b.minY));
for (const [i, element] of elements.entries()) {
const elementBound = bounds[i];
const bound = Bound.deserialize(element.xywh);
const offset = bound.minY - elementBound.minY;
bound.y = top + offset;
updateXYWHWith(ctx, element, bound);
}
},
[Alignment.Vertically](ctx: ToolbarContext, elements: GfxModel[]) {
const bounds = elements.map(a => a.elementBound);
const top = Math.min(...bounds.map(b => b.minY));
const bottom = Math.max(...bounds.map(b => b.maxY));
const centerY = (top + bottom) / 2;
for (const element of elements) {
const bound = Bound.deserialize(element.xywh);
bound.y = centerY - bound.h / 2;
updateXYWHWith(ctx, element, bound);
}
},
[Alignment.Bottom](ctx: ToolbarContext, elements: GfxModel[]) {
const bounds = elements.map(a => a.elementBound);
const bottom = Math.max(...bounds.map(b => b.maxY));
for (const [i, element] of elements.entries()) {
const elementBound = bounds[i];
const bound = Bound.deserialize(element.xywh);
const offset = bound.maxY - elementBound.maxY;
bound.y = bottom - bound.h + offset;
updateXYWHWith(ctx, element, bound);
}
},
[Alignment.DistributeVertically](ctx: ToolbarContext, elements: GfxModel[]) {
elements.sort((a, b) => a.elementBound.minY - b.elementBound.minY);
const bounds = elements.map(a => a.elementBound);
const top = bounds[0].minY;
const bottom = bounds[bounds.length - 1].maxY;
const totalHeight = bottom - top;
const totalGap =
totalHeight -
elements.reduce((prev, ele) => prev + ele.elementBound.h, 0);
const gap = totalGap / (elements.length - 1);
let next = bounds[0].maxY + gap;
for (let i = 1; i < elements.length - 1; i++) {
const bound = Bound.deserialize(elements[i].xywh);
bound.y = next + bounds[i].h / 2 - bound.h / 2;
next += gap + bounds[i].h;
updateXYWHWith(ctx, elements[i], bound);
}
},
// Auto
[Alignment.AutoArrange](ctx: ToolbarContext) {
ctx.command.exec(autoArrangeElementsCommand);
},
[Alignment.AutoResize](ctx: ToolbarContext) {
ctx.command.exec(autoResizeElementsCommand);
},
} as const satisfies AlignmentMap;
const updateXYWHWith = (ctx: ToolbarContext, model: GfxModel, bound: Bound) => {
updateXYWH(
model,
bound,
ctx.std.get(EdgelessCRUDIdentifier).updateElement,
ctx.store.updateBlock
);
};
export function renderAlignmentMenu(
ctx: ToolbarContext,
models: GfxModel[],
{ label, tooltip, icon }: Pick<Menu<Alignment>, 'label' | 'tooltip' | 'icon'>,
onPick = (type: Alignment) => alignment[type](ctx, models)
) {
return html`
<editor-menu-button
aria-label="alignment-menu"
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button
aria-label="${label}"
.tooltip="${tooltip ?? label}"
>
${icon} ${EditorChevronDown}
</editor-icon-button>
`}
>
<div data-orientation="vertical">
<div style=${styleMap({ display: 'grid', gridGap: '8px', gridTemplateColumns: 'repeat(4, 1fr)' })}>
${renderMenuItems(HORIZONTAL_ALIGNMENT, Alignment.None, onPick)}
${renderMenuItems(VERTICAL_ALIGNMENT, Alignment.None, onPick)}
</div>
<editor-toolbar-separator data-orientation="horizontal"></editor-toolbar-separator>
<div style=${styleMap({ display: 'grid', gridGap: '8px', gridTemplateColumns: 'repeat(4, 1fr)' })}>
${renderMenuItems(AUTO_ALIGNMENT, Alignment.None, onPick)}
</div>
</editor-menu-button>
`;
}

View File

@@ -0,0 +1,53 @@
import { edgelessTextToolbarExtension } from '@blocksuite/affine-block-edgeless-text';
import { frameToolbarExtension } from '@blocksuite/affine-block-frame';
import {
brushToolbarExtension,
highlighterToolbarExtension,
} from '@blocksuite/affine-gfx-brush';
import { connectorToolbarExtension } from '@blocksuite/affine-gfx-connector';
import { groupToolbarExtension } from '@blocksuite/affine-gfx-group';
import {
mindmapToolbarExtension,
shapeMindmapToolbarExtension,
} from '@blocksuite/affine-gfx-mindmap';
import { shapeToolbarExtension } from '@blocksuite/affine-gfx-shape';
import { textToolbarExtension } from '@blocksuite/affine-gfx-text';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import { BlockFlavourIdentifier } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { builtinLockedToolbarConfig, builtinMiscToolbarConfig } from './misc';
export const EdgelessElementToolbarExtension: ExtensionType[] = [
frameToolbarExtension,
groupToolbarExtension,
brushToolbarExtension,
highlighterToolbarExtension,
connectorToolbarExtension,
shapeToolbarExtension,
shapeMindmapToolbarExtension,
mindmapToolbarExtension,
textToolbarExtension,
edgelessTextToolbarExtension,
ToolbarModuleExtension({
id: BlockFlavourIdentifier('affine:surface:*'),
config: builtinMiscToolbarConfig,
}),
// Special Scenarios
// Only display the `unlock` button when the selection includes a locked element.
ToolbarModuleExtension({
id: BlockFlavourIdentifier('affine:surface:locked'),
config: builtinLockedToolbarConfig,
}),
];

View File

@@ -0,0 +1,368 @@
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
import {
EdgelessCRUDIdentifier,
getSurfaceComponent,
} from '@blocksuite/affine-block-surface';
import {
createGroupCommand,
createGroupFromSelectedCommand,
ungroupCommand,
} from '@blocksuite/affine-gfx-group';
import {
ConnectorElementModel,
DEFAULT_CONNECTOR_MODE,
GroupElementModel,
MindmapElementModel,
} from '@blocksuite/affine-model';
import {
ActionPlacement,
type ElementLockEvent,
type ToolbarAction,
type ToolbarContext,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { Bound } from '@blocksuite/global/gfx';
import {
AlignLeftIcon,
ConnectorCIcon,
FrameIcon,
GroupingIcon,
LockIcon,
ReleaseFromGroupIcon,
UnlockIcon,
} from '@blocksuite/icons/lit';
import type { GfxModel } from '@blocksuite/std/gfx';
import { html } from 'lit';
import { renderAlignmentMenu } from './alignment';
import { moreActions } from './more';
export const builtinMiscToolbarConfig = {
actions: [
{
placement: ActionPlacement.Start,
id: 'a.release-from-group',
tooltip: 'Release from group',
icon: ReleaseFromGroupIcon(),
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length !== 1) return false;
return ctx.matchModel(models[0].group, GroupElementModel);
},
run(ctx) {
const models = ctx.getSurfaceModels();
if (models.length !== 1) return;
const firstModel = models[0];
if (firstModel.isLocked()) return;
if (!ctx.matchModel(firstModel.group, GroupElementModel)) return;
const group = firstModel.group;
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group.removeChild(firstModel);
firstModel.index = ctx.gfx.layer.generateIndex();
const parent = group.group;
if (parent && parent instanceof GroupElementModel) {
parent.addChild(firstModel);
}
},
},
{
placement: ActionPlacement.Start,
id: 'b.add-frame',
label: 'Frame',
showLabel: true,
tooltip: 'Frame',
icon: FrameIcon(),
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length < 2) return false;
if (
models.some(model => ctx.matchModel(model.group, MindmapElementModel))
)
return false;
if (
models.length ===
models.filter(model => model instanceof ConnectorElementModel).length
)
return false;
return true;
},
run(ctx) {
const models = ctx.getSurfaceModels();
if (models.length < 2) return;
const surface = getSurfaceComponent(ctx.std);
if (!surface) return;
const frameManager = ctx.std.get(EdgelessFrameManagerIdentifier);
const frame = frameManager.createFrameOnSelected();
if (!frame) return;
// TODO(@fundon): should be a command
surface.fitToViewport(Bound.deserialize(frame.xywh));
ctx.track('CanvasElementAdded', {
control: 'context-menu',
type: 'frame',
});
},
},
{
placement: ActionPlacement.Start,
id: 'c.add-group',
label: 'Group',
showLabel: true,
tooltip: 'Group',
icon: GroupingIcon(),
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length < 2) return false;
if (ctx.matchModel(models[0], GroupElementModel)) return false;
if (
models.some(model => ctx.matchModel(model.group, MindmapElementModel))
)
return false;
if (
models.length ===
models.filter(model => ctx.matchModel(model, ConnectorElementModel))
.length
)
return false;
return true;
},
run(ctx) {
const models = ctx.getSurfaceModels();
if (models.length < 2) return;
// TODO(@fundon): should be a command
ctx.command.exec(createGroupFromSelectedCommand);
},
},
{
placement: ActionPlacement.Start,
id: 'd.alignment',
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length < 2) return false;
if (models.some(model => model.group instanceof MindmapElementModel))
return false;
if (
models.length ===
models.filter(model => model instanceof ConnectorElementModel).length
)
return false;
return true;
},
content(ctx) {
const models = ctx.getSurfaceModels();
if (models.length < 2) return null;
return renderAlignmentMenu(ctx, models, {
icon: AlignLeftIcon(),
label: 'Align objects',
tooltip: 'Align objects',
});
},
},
{
placement: ActionPlacement.End,
id: 'a.draw-connector',
label: 'Draw connector',
tooltip: 'Draw connector',
icon: ConnectorCIcon(),
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length !== 1) return false;
return !ctx.matchModel(models[0], ConnectorElementModel);
},
content(ctx) {
const models = ctx.getSurfaceModels();
if (!models.length) return null;
const { label, icon, tooltip } = this;
const quickConnect = (e: MouseEvent) => {
e.stopPropagation();
const { x, y } = e;
const point = ctx.gfx.viewport.toViewCoordFromClientCoord([x, y]);
ctx.store.captureSync();
ctx.gfx.tool.setTool('connector', { mode: DEFAULT_CONNECTOR_MODE });
const ctc = ctx.gfx.tool.get('connector');
ctc.quickConnect(point, models[0]);
};
return html`
<editor-icon-button
data-testid="${'draw-connector'}"
aria-label=${label}
.tooltip=${tooltip}
@click=${quickConnect}
>
${icon}
</editor-icon-button>
`;
},
} satisfies ToolbarAction,
{
placement: ActionPlacement.End,
id: 'b.lock',
tooltip: 'Lock',
icon: LockIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
if (!models.length) return;
// get most top selected elements(*) from tree, like in a tree below
// G0
// / \
// E1* G1
// / \
// E2* E3*
//
// (*) selected elements, [E1, E2, E3]
// return [E1]
const elements = Array.from(
new Set(
models.map(model =>
ctx.matchModel(model.group, MindmapElementModel)
? model.group
: model
)
)
);
const levels = elements.map(element => element.groups.length);
const topElement = elements[levels.indexOf(Math.min(...levels))];
const otherElements = elements.filter(
element => element !== topElement
);
ctx.store.captureSync();
// release other elements from their groups and group with top element
otherElements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
element.group?.removeChild(element);
topElement.group?.addChild(element);
});
if (otherElements.length === 0) {
topElement.lock();
ctx.gfx.selection.set({
editing: false,
elements: [topElement.id],
});
track(ctx, topElement, 'lock');
return;
}
const [_, { groupId }] = ctx.command.exec(createGroupCommand, {
elements: [topElement, ...otherElements],
});
if (groupId) {
const element = ctx.std
.get(EdgelessCRUDIdentifier)
.getElementById(groupId);
if (element) {
element.lock();
ctx.gfx.selection.set({
editing: false,
elements: [groupId],
});
track(ctx, element, 'group-lock');
return;
}
}
for (const element of elements) {
element.lock();
track(ctx, element, 'lock');
}
ctx.gfx.selection.set({
editing: false,
elements: elements.map(e => e.id),
});
},
},
// More actions
...moreActions.map(action => ({
...action,
placement: ActionPlacement.More,
})),
],
when(ctx) {
const models = ctx.getSurfaceModels();
return models.length > 0 && !models.some(model => model.isLocked());
},
} as const satisfies ToolbarModuleConfig;
export const builtinLockedToolbarConfig = {
actions: [
{
placement: ActionPlacement.End,
id: 'b.unlock',
label: 'Click to unlock',
showLabel: true,
icon: UnlockIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
if (!models.length) return;
const elements = new Set(
models.map(model =>
ctx.matchModel(model.group, MindmapElementModel)
? model.group
: model
)
);
ctx.store.captureSync();
for (const element of elements) {
if (element instanceof GroupElementModel) {
ctx.command.exec(ungroupCommand, { group: element });
} else {
element.lockedBySelf = false;
}
track(ctx, element, 'unlock');
}
},
},
],
when: ctx => ctx.getSurfaceModels().some(model => model.isLocked()),
} as const satisfies ToolbarModuleConfig;
function track(
ctx: ToolbarContext,
element: GfxModel,
control: ElementLockEvent['control']
) {
ctx.track('EdgelessElementLocked', {
control,
type:
'type' in element
? element.type
: (element.flavour.split(':')[1] ?? element.flavour),
});
}

View File

@@ -0,0 +1,421 @@
import { AttachmentBlockComponent } from '@blocksuite/affine-block-attachment';
import { BookmarkBlockComponent } from '@blocksuite/affine-block-bookmark';
import {
isExternalEmbedBlockComponent,
notifyDocCreated,
promptDocTitle,
} from '@blocksuite/affine-block-embed';
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
import { ImageBlockComponent } from '@blocksuite/affine-block-image';
import {
EdgelessCRUDIdentifier,
getSurfaceComponent,
} from '@blocksuite/affine-block-surface';
import { createGroupFromSelectedCommand } from '@blocksuite/affine-gfx-group';
import {
AttachmentBlockModel,
BookmarkBlockModel,
EmbedLinkedDocBlockSchema,
EmbedLinkedDocModel,
EmbedSyncedDocBlockSchema,
EmbedSyncedDocModel,
FrameBlockModel,
ImageBlockModel,
isExternalEmbedModel,
NoteBlockModel,
} from '@blocksuite/affine-model';
import type {
ToolbarActions,
ToolbarContext,
} from '@blocksuite/affine-shared/services';
import { type ReorderingType } from '@blocksuite/affine-shared/utils';
import { Bound, getCommonBoundWithRotation } from '@blocksuite/global/gfx';
import {
ArrowDownBigBottomIcon,
ArrowDownBigIcon,
ArrowUpBigIcon,
ArrowUpBigTopIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
FrameIcon,
GroupIcon,
LinkedPageIcon,
ResetIcon,
} from '@blocksuite/icons/lit';
import type { BlockComponent } from '@blocksuite/std';
import { GfxBlockElementModel, type GfxModel } from '@blocksuite/std/gfx';
import { EdgelessClipboardController } from '../../clipboard/clipboard';
import { duplicate } from '../../utils/clipboard-utils';
import { getSortedCloneElements } from '../../utils/clone-utils';
import { moveConnectors } from '../../utils/connector';
import { deleteElements } from '../../utils/crud';
import {
createLinkedDocFromEdgelessElements,
createLinkedDocFromNote,
} from './render-linked-doc';
import { getEdgelessWith } from './utils';
export const moreActions = [
// Selection Group: frame & group
{
id: 'Z.a.selection',
actions: [
{
id: 'a.create-frame',
label: 'Frame section',
icon: FrameIcon(),
run(ctx) {
const frame = ctx.std
.get(EdgelessFrameManagerIdentifier)
.createFrameOnSelected();
if (!frame) return;
const surface = getSurfaceComponent(ctx.std);
if (!surface) return;
surface.fitToViewport(Bound.deserialize(frame.xywh));
ctx.track('CanvasElementAdded', {
control: 'context-menu',
type: 'frame',
});
},
},
{
id: 'b.create-group',
label: 'Group section',
icon: GroupIcon(),
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length === 0) return false;
return !models.some(model => ctx.matchModel(model, FrameBlockModel));
},
run(ctx) {
ctx.command.exec(createGroupFromSelectedCommand);
},
},
],
},
// Reordering Group
{
id: 'Z.b.reordering',
actions: [
{
id: 'a.bring-to-front',
label: 'Bring to Front',
icon: ArrowUpBigTopIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
reorderElements(ctx, models, 'front');
},
},
{
id: 'b.bring-forward',
label: 'Bring Forward',
icon: ArrowUpBigIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
reorderElements(ctx, models, 'forward');
},
},
{
id: 'c.send-backward',
label: 'Send Backward',
icon: ArrowDownBigIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
reorderElements(ctx, models, 'backward');
},
},
{
id: 'c.send-to-back',
label: 'Send to Back',
icon: ArrowDownBigBottomIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
reorderElements(ctx, models, 'back');
},
},
],
},
// Clipboard Group
// Uses the same `ID` for both page and edgeless modes.
{
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
if (!models.length) return;
const edgelessClipboard = ctx.std.getOptional(
EdgelessClipboardController
);
if (!edgelessClipboard) return;
edgelessClipboard.copy();
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
if (!models.length) return;
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
duplicate(edgeless, models).catch(console.error);
},
},
{
id: 'reload',
label: 'Reload',
icon: ResetIcon(),
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length === 0) return false;
return models.every(isRefreshableModel);
},
run(ctx) {
const blocks = ctx
.getSurfaceModels()
.map(model => ctx.view.getBlock(model.id))
.filter(isRefreshableBlock);
if (!blocks.length) return;
for (const block of blocks) {
block.refreshData();
}
},
},
],
},
// Conversions Group
{
id: 'd.conversions',
actions: [
{
id: 'a.turn-into-linked-doc',
label: 'Turn into linked doc',
icon: LinkedPageIcon(),
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length !== 1) return false;
return ctx.matchModel(models[0], NoteBlockModel);
},
run(ctx) {
const model = ctx.getCurrentModelByType(NoteBlockModel);
if (!model) return;
const create = async () => {
const title = await promptDocTitle(ctx.std);
if (title === null) return;
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
const surfaceId = edgeless.surfaceBlockModel.id;
if (!surfaceId) return;
const linkedDoc = createLinkedDocFromNote(ctx.store, model, title);
if (!linkedDoc) return;
// Inserts linked doc card
const cardId = ctx.std.get(EdgelessCRUDIdentifier).addBlock(
EmbedSyncedDocBlockSchema.model.flavour,
{
xywh: model.xywh,
style: 'syncedDoc',
pageId: linkedDoc.id,
index: model.index,
},
surfaceId
);
ctx.track('CanvasElementAdded', {
control: 'context-menu',
type: 'embed-synced-doc',
});
ctx.track('DocCreated', {
control: 'turn into linked doc',
type: 'embed-linked-doc',
});
ctx.track('LinkedDocCreated', {
control: 'turn into linked doc',
type: 'embed-linked-doc',
other: 'new doc',
});
moveConnectors(model.id, cardId, edgeless.service);
// Deletes selected note
ctx.store.transact(() => {
ctx.store.deleteBlock(model);
});
ctx.gfx.selection.set({
elements: [cardId],
editing: false,
});
};
create().catch(console.error);
},
},
{
id: 'b.create-linked-doc',
label: 'Create linked doc',
icon: LinkedPageIcon(),
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length === 0) return false;
if (models.length === 1) {
return ![
NoteBlockModel,
EmbedLinkedDocModel,
EmbedSyncedDocModel,
].some(k => ctx.matchModel(models[0], k));
}
return true;
},
run(ctx) {
const models = ctx.getSurfaceModels();
if (!models.length) return;
const create = async () => {
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
const surfaceId = edgeless.surfaceBlockModel.id;
if (!surfaceId) return;
const title = await promptDocTitle(ctx.std);
if (title === null) return;
const clonedModels = getSortedCloneElements(models);
const linkedDoc = createLinkedDocFromEdgelessElements(
ctx.host,
clonedModels,
title
);
ctx.store.transact(() => {
deleteElements(edgeless, clonedModels);
});
// Inserts linked doc card
const width = 364;
const height = 390;
const bound = getCommonBoundWithRotation(clonedModels);
const cardId = ctx.std.get(EdgelessCRUDIdentifier).addBlock(
EmbedLinkedDocBlockSchema.model.flavour,
{
xywh: `[${bound.center[0] - width / 2}, ${bound.center[1] - height / 2}, ${width}, ${height}]`,
style: 'vertical',
pageId: linkedDoc.id,
},
surfaceId
);
ctx.gfx.selection.set({
elements: [cardId],
editing: false,
});
ctx.track('CanvasElementAdded', {
control: 'context-menu',
type: 'embed-linked-doc',
});
ctx.track('DocCreated', {
control: 'create linked doc',
type: 'embed-linked-doc',
});
ctx.track('LinkedDocCreated', {
control: 'create linked doc',
type: 'embed-linked-doc',
other: 'new doc',
});
notifyDocCreated(ctx.std, ctx.store);
};
create().catch(console.error);
},
},
],
},
// Deleting Group
{
id: 'e.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const models = ctx.getSurfaceModels();
if (!models.length) return;
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
ctx.store.captureSync();
deleteElements(edgeless, models);
// Clears
ctx.select('surface');
ctx.reset();
},
},
] as const satisfies ToolbarActions;
function reorderElements(
ctx: ToolbarContext,
models: GfxModel[],
type: ReorderingType
) {
if (!models.length) return;
for (const model of models) {
const index = ctx.gfx.layer.getReorderedIndex(model, type);
// block should be updated in transaction
if (model instanceof GfxBlockElementModel) {
ctx.store.transact(() => {
model.index = index;
});
} else {
model.index = index;
}
}
}
function isRefreshableModel(model: GfxModel) {
return (
model instanceof AttachmentBlockModel ||
model instanceof BookmarkBlockModel ||
model instanceof ImageBlockModel ||
isExternalEmbedModel(model)
);
}
function isRefreshableBlock(block: BlockComponent | null) {
return (
!!block &&
(block instanceof AttachmentBlockComponent ||
block instanceof BookmarkBlockComponent ||
block instanceof ImageBlockComponent ||
isExternalEmbedBlockComponent(block))
);
}

View File

@@ -0,0 +1,91 @@
import { isFrameBlock } from '@blocksuite/affine-block-frame';
import { getSurfaceBlock, isNoteBlock } from '@blocksuite/affine-block-surface';
import type { FrameBlockModel, NoteBlockModel } from '@blocksuite/affine-model';
import { replaceIdMiddleware } from '@blocksuite/affine-shared/adapters';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
import type { EditorHost } from '@blocksuite/std';
import { GfxBlockElementModel, type GfxModel } from '@blocksuite/std/gfx';
import { type Store, Text } from '@blocksuite/store';
import {
getElementProps,
mapFrameIds,
sortEdgelessElements,
} from '../../../edgeless/utils/clone-utils.js';
export function createLinkedDocFromNote(
doc: Store,
note: NoteBlockModel,
docTitle?: string
) {
const _doc = doc.workspace.createDoc();
const transformer = doc.getTransformer([
replaceIdMiddleware(doc.workspace.idGenerator),
]);
const blockSnapshot = transformer.blockToSnapshot(note);
if (!blockSnapshot) {
console.error('Failed to create linked doc from note');
return;
}
const linkedDoc = _doc.getStore({ id: doc.id });
linkedDoc.load(() => {
const rootId = linkedDoc.addBlock('affine:page', {
title: new Text(docTitle),
});
linkedDoc.addBlock('affine:surface', {}, rootId);
transformer
.snapshotToBlock(blockSnapshot, linkedDoc, rootId)
.catch(console.error);
});
return linkedDoc;
}
export function createLinkedDocFromEdgelessElements(
host: EditorHost,
elements: GfxModel[],
docTitle?: string
) {
const _doc = host.doc.workspace.createDoc();
const transformer = host.doc.getTransformer();
const linkedDoc = _doc.getStore();
linkedDoc.load(() => {
const rootId = linkedDoc.addBlock('affine:page', {
title: new Text(docTitle),
});
const surfaceId = linkedDoc.addBlock('affine:surface', {}, rootId);
const surface = getSurfaceBlock(linkedDoc);
if (!surface) return;
const sortedElements = sortEdgelessElements(elements);
const ids = new Map<string, string>();
sortedElements.forEach(model => {
let newId = model.id;
if (model instanceof GfxBlockElementModel) {
const blockProps = getBlockProps(model);
if (isNoteBlock(model)) {
const blockSnapshot = transformer.blockToSnapshot(model);
if (blockSnapshot) {
transformer
.snapshotToBlock(blockSnapshot, linkedDoc, rootId)
.catch(console.error);
}
} else {
if (isFrameBlock(model)) {
mapFrameIds(blockProps as FrameBlockModel['props'], ids);
}
newId = linkedDoc.addBlock(model.flavour, blockProps, surfaceId);
}
} else {
const props = getElementProps(model, ids);
newId = surface.addElement(props);
}
ids.set(model.id, newId);
});
});
host.std.get(DocModeProvider).setPrimaryMode('edgeless', linkedDoc.id);
return linkedDoc;
}

View File

@@ -0,0 +1,17 @@
import type { ToolbarContext } from '@blocksuite/affine-shared/services';
import { EdgelessRootBlockComponent } from '../..';
// TODO(@fundon): it should be simple
export function getEdgelessWith(ctx: ToolbarContext) {
const rootModel = ctx.store.root;
if (!rootModel) return;
const edgeless = ctx.view.getBlock(rootModel.id);
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
console.error('edgeless view is not found.');
return;
}
return edgeless;
}

View File

@@ -0,0 +1,75 @@
import {
FrameHighlightManager,
FrameTool,
PresentTool,
} from '@blocksuite/affine-block-frame';
import { ConnectionOverlay } from '@blocksuite/affine-block-surface';
import {
BrushTool,
EraserTool,
HighlighterTool,
} from '@blocksuite/affine-gfx-brush';
import {
ConnectorFilter,
ConnectorTool,
} from '@blocksuite/affine-gfx-connector';
import {
MindMapDragExtension,
MindMapIndicatorOverlay,
} from '@blocksuite/affine-gfx-mindmap';
import { NoteTool } from '@blocksuite/affine-gfx-note';
import { ShapeTool } from '@blocksuite/affine-gfx-shape';
import { TextTool } from '@blocksuite/affine-gfx-text';
import { ElementTransformManager } from '@blocksuite/std/gfx';
import type { ExtensionType } from '@blocksuite/store';
import { EdgelessElementToolbarExtension } from './configs/toolbar';
import { EdgelessRootBlockSpec } from './edgeless-root-spec.js';
import { DblClickAddEdgelessText } from './element-transform/dblclick-add-edgeless-text.js';
import { SnapExtension } from './element-transform/snap-manager.js';
import { DefaultTool } from './gfx-tool/default-tool.js';
import { EmptyTool } from './gfx-tool/empty-tool.js';
import { PanTool } from './gfx-tool/pan-tool.js';
import { TemplateTool } from './gfx-tool/template-tool.js';
import { EditPropsMiddlewareBuilder } from './middlewares/base.js';
import { SnapOverlay } from './utils/snap-manager.js';
export const EdgelessToolExtension: ExtensionType[] = [
DefaultTool,
PanTool,
EraserTool,
TextTool,
ShapeTool,
NoteTool,
BrushTool,
ConnectorTool,
TemplateTool,
EmptyTool,
FrameTool,
PresentTool,
HighlighterTool,
];
export const EdgelessEditExtensions: ExtensionType[] = [
ElementTransformManager,
ConnectorFilter,
SnapExtension,
MindMapDragExtension,
FrameHighlightManager,
DblClickAddEdgelessText,
];
export const EdgelessBuiltInManager: ExtensionType[] = [
ConnectionOverlay,
MindMapIndicatorOverlay,
SnapOverlay,
EditPropsMiddlewareBuilder,
EdgelessElementToolbarExtension,
].flat();
export const EdgelessBuiltInSpecs: ExtensionType[] = [
EdgelessRootBlockSpec,
EdgelessToolExtension,
EdgelessBuiltInManager,
EdgelessEditExtensions,
].flat();

View File

@@ -0,0 +1,703 @@
import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmark';
import { EdgelessTextBlockComponent } from '@blocksuite/affine-block-edgeless-text';
import { isNoteBlock } from '@blocksuite/affine-block-surface';
import { toast } from '@blocksuite/affine-components/toast';
import { mountConnectorLabelEditor } from '@blocksuite/affine-gfx-connector';
import {
createGroupFromSelectedCommand,
ungroupCommand,
} from '@blocksuite/affine-gfx-group';
import {
getNearestTranslation,
isElementOutsideViewport,
isSingleMindMapNode,
} from '@blocksuite/affine-gfx-mindmap';
import { mountShapeTextEditor, ShapeTool } from '@blocksuite/affine-gfx-shape';
import {
ConnectorElementModel,
ConnectorMode,
EdgelessTextBlockModel,
GroupElementModel,
LayoutType,
MindmapElementModel,
NoteBlockModel,
NoteDisplayMode,
type ShapeElementModel,
} from '@blocksuite/affine-model';
import {
EditPropsStore,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { IS_MAC } from '@blocksuite/global/env';
import { Bound, getCommonBound } from '@blocksuite/global/gfx';
import { SurfaceSelection, TextSelection } from '@blocksuite/std';
import {
GfxBlockElementModel,
type GfxPrimitiveElementModel,
type GfxToolsMap,
type GfxToolsOption,
isGfxGroupCompatibleModel,
} from '@blocksuite/std/gfx';
import { PageKeyboardManager } from '../keyboard/keyboard-manager.js';
import type { EdgelessRootBlockComponent } from './edgeless-root-block.js';
import {
DEFAULT_NOTE_CHILD_FLAVOUR,
DEFAULT_NOTE_CHILD_TYPE,
DEFAULT_NOTE_TIP,
} from './utils/consts.js';
import { deleteElements } from './utils/crud.js';
import { getNextShapeType } from './utils/hotkey-utils.js';
import { isCanvasElement } from './utils/query.js';
export class EdgelessPageKeyboardManager extends PageKeyboardManager {
get gfx() {
return this.rootComponent.gfx;
}
constructor(override rootComponent: EdgelessRootBlockComponent) {
super(rootComponent);
this.rootComponent.bindHotKey(
{
v: () => {
this._setEdgelessTool('default');
},
t: () => {
this._setEdgelessTool('text');
},
c: () => {
const mode = ConnectorMode.Curve;
rootComponent.std.get(EditPropsStore).recordLastProps('connector', {
mode,
});
this._setEdgelessTool('connector', { mode });
},
h: () => {
this._setEdgelessTool('pan', {
panning: false,
});
},
n: () => {
this._setEdgelessTool('affine:note', {
childFlavour: DEFAULT_NOTE_CHILD_FLAVOUR,
childType: DEFAULT_NOTE_CHILD_TYPE,
tip: DEFAULT_NOTE_TIP,
});
},
p: () => {
this._setEdgelessTool('brush');
},
e: () => {
this._setEdgelessTool('eraser');
},
k: () => {
if (this.rootComponent.service.locked) return;
const { selection } = rootComponent.service;
if (
selection.selectedElements.length === 1 &&
selection.firstElement instanceof GfxBlockElementModel &&
matchModels(selection.firstElement as GfxBlockElementModel, [
NoteBlockModel,
])
) {
rootComponent.slots.toggleNoteSlicer.next();
}
},
f: () => {
if (this.rootComponent.service.locked) return;
if (
this.rootComponent.service.selection.selectedElements.length !==
0 &&
!this.rootComponent.service.selection.editing
) {
const frame = rootComponent.service.frame.createFrameOnSelected();
if (!frame) return;
this.rootComponent.std
.getOptional(TelemetryProvider)
?.track('CanvasElementAdded', {
control: 'shortcut',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'frame',
});
rootComponent.surface.fitToViewport(Bound.deserialize(frame.xywh));
} else if (!this.rootComponent.service.selection.editing) {
this._setEdgelessTool('frame');
}
},
'-': () => {
if (this.rootComponent.service.locked) return;
const { selectedElements: elements } =
rootComponent.service.selection;
if (
!rootComponent.service.selection.editing &&
elements.length === 1 &&
isNoteBlock(elements[0])
) {
rootComponent.slots.toggleNoteSlicer.next();
}
},
'@': () => {
const std = this.rootComponent.std;
if (
std.selection.getGroup('note').length > 0 ||
std.selection.find(TextSelection) ||
Boolean(std.selection.find(SurfaceSelection)?.editing)
) {
return;
}
const [_, { insertedLinkType }] = std.command.exec(
insertLinkByQuickSearchCommand
);
insertedLinkType
?.then(type => {
const flavour = type?.flavour;
if (!flavour) return;
rootComponent.std
.getOptional(TelemetryProvider)
?.track('CanvasElementAdded', {
control: 'shortcut',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: flavour.split(':')[1],
});
})
.catch(console.error);
},
'Shift-s': () => {
if (this.rootComponent.service.locked) return;
const controller = rootComponent.gfx.tool.currentTool$.peek();
if (
this.rootComponent.service.selection.editing ||
!(controller instanceof ShapeTool)
) {
return;
}
const { shapeName } = controller.activatedOption;
const nextShapeName = getNextShapeType(shapeName);
this._setEdgelessTool('shape', {
shapeName: nextShapeName,
});
controller.createOverlay();
},
'Mod-g': ctx => {
if (this.rootComponent.service.locked) return;
if (
this.rootComponent.service.selection.selectedElements.length > 1 &&
!this.rootComponent.service.selection.editing
) {
ctx.get('keyboardState').event.preventDefault();
rootComponent.std.command.exec(createGroupFromSelectedCommand);
}
},
'Shift-Mod-g': ctx => {
if (this.rootComponent.service.locked) return;
const { selection } = this.rootComponent.service;
if (
selection.selectedElements.length === 1 &&
selection.firstElement instanceof GroupElementModel &&
!selection.firstElement.isLocked()
) {
ctx.get('keyboardState').event.preventDefault();
rootComponent.std.command.exec(ungroupCommand, {
group: selection.firstElement,
});
}
},
'Mod-a': ctx => {
if (this.rootComponent.service.locked) return;
if (this.rootComponent.service.selection.editing) {
return;
}
ctx.get('defaultState').event.preventDefault();
const { service } = this.rootComponent;
this.rootComponent.service.selection.set({
elements: [
...service.blocks
.filter(
block =>
block.group === null &&
!(
matchModels(block, [NoteBlockModel]) &&
block.props.displayMode === NoteDisplayMode.DocOnly
)
)
.map(block => block.id),
...service.elements
.filter(el => el.group === null)
.map(el => el.id),
],
editing: false,
});
},
'Mod--': ctx => {
ctx.get('defaultState').event.preventDefault();
this.rootComponent.service.setZoomByAction('out');
},
'Alt-0': ctx => {
ctx.get('defaultState').event.preventDefault();
this.rootComponent.service.setZoomByAction('reset');
},
'Alt-1': ctx => {
ctx.get('defaultState').event.preventDefault();
this.rootComponent.service.setZoomByAction('fit');
},
'Alt-2': ctx => {
ctx.get('defaultState').event.preventDefault();
const selectedElements = this.gfx.selection.selectedElements;
if (selectedElements.length === 0) {
return;
}
const bound = getCommonBound(selectedElements);
if (bound === null) {
return;
}
toast(this.rootComponent.host, 'Zoom to selection');
this.gfx.viewport.setViewportByBound(
bound,
[0.12, 0.12, 0.12, 0.12],
true
);
},
'Mod-=': ctx => {
ctx.get('defaultState').event.preventDefault();
this.rootComponent.service.setZoomByAction('in');
},
Backspace: () => {
this._delete();
},
Delete: () => {
this._delete();
},
'Control-d': () => {
if (!IS_MAC) return;
this._delete();
},
Escape: () => {
if (!this.rootComponent.service.selection.empty) {
rootComponent.selection.clear();
}
},
ArrowUp: () => {
this._move('ArrowUp');
},
ArrowDown: () => {
this._move('ArrowDown');
},
ArrowLeft: () => {
this._move('ArrowLeft');
},
ArrowRight: () => {
this._move('ArrowRight');
},
'Shift-ArrowUp': () => {
this._move('ArrowUp', true);
},
'Shift-ArrowDown': () => {
this._move('ArrowDown', true);
},
'Shift-ArrowLeft': () => {
this._move('ArrowLeft', true);
},
'Shift-ArrowRight': () => {
this._move('ArrowRight', true);
},
Enter: () => {
const { service } = rootComponent;
const selection = service.selection;
const elements = selection.selectedElements;
const onlyOne = elements.length === 1;
if (onlyOne) {
const element = elements[0];
const id = element.id;
if (element.isLocked()) return;
if (element instanceof ConnectorElementModel) {
selection.set({
elements: [id],
editing: true,
});
requestAnimationFrame(() => {
mountConnectorLabelEditor(element, rootComponent);
});
return;
}
if (element instanceof EdgelessTextBlockModel) {
selection.set({
elements: [id],
editing: true,
});
const textBlock = rootComponent.host.view.getBlock(id);
if (textBlock instanceof EdgelessTextBlockComponent) {
textBlock.tryFocusEnd();
}
return;
}
}
if (!isSingleMindMapNode(elements)) {
return;
}
const mindmap = elements[0].group as MindmapElementModel;
const currentNode = mindmap.getNode(elements[0].id)!;
const node = mindmap.getNode(elements[0].id)!;
const parent = mindmap.getParentNode(node.id) ?? node;
const id = mindmap.addNode(parent.id, currentNode.id, 'after');
const target = service.crud.getElementById(id) as ShapeElementModel;
requestAnimationFrame(() => {
mountShapeTextEditor(target, rootComponent);
if (isElementOutsideViewport(service.viewport, target, [20, 20])) {
const { elementBound } = target;
service.viewport.smoothTranslate(
elementBound.x + elementBound.w / 2,
elementBound.y + elementBound.h / 2
);
}
});
},
Tab: ctx => {
ctx.get('defaultState').event.preventDefault();
const { service } = rootComponent;
const selection = service.selection;
const elements = selection.selectedElements;
if (!isSingleMindMapNode(elements)) {
return;
}
const mindmap = elements[0].group as MindmapElementModel;
if (mindmap.isLocked()) return;
const node = mindmap.getNode(elements[0].id)!;
const id = mindmap.addNode(node.id);
const target = service.crud.getElementById(id) as ShapeElementModel;
if (node.detail.collapsed) {
mindmap.toggleCollapse(node, { layout: true });
}
requestAnimationFrame(() => {
mountShapeTextEditor(target, rootComponent);
if (isElementOutsideViewport(service.viewport, target, [20, 20])) {
const { elementBound } = target;
service.viewport.smoothTranslate(
elementBound.x + elementBound.w / 2,
elementBound.y + elementBound.h / 2
);
}
});
},
},
{
global: true,
}
);
this._bindToggleHand();
}
private _bindToggleHand() {
this.rootComponent.handleEvent(
'keyDown',
ctx => {
const event = ctx.get('keyboardState').raw;
const gfx = this.rootComponent.gfx;
const selection = gfx.selection;
if (event.code === 'Space' && !event.repeat) {
this._space(event);
} else if (
!selection.editing &&
// the key might be `Unidentified` according to mdn
event.key?.length === 1 &&
!event.shiftKey &&
!event.ctrlKey &&
!event.altKey &&
!event.metaKey
) {
const elements = selection.selectedElements;
const doc = this.rootComponent.doc;
if (isSingleMindMapNode(elements)) {
const target = gfx.getElementById(
elements[0].id
) as ShapeElementModel;
if (target.text) {
doc.transact(() => {
target.text!.delete(0, target.text!.length);
target.text!.insert(0, event.key);
});
}
mountShapeTextEditor(target, this.rootComponent);
return true;
}
}
return false;
},
{ global: true }
);
this.rootComponent.handleEvent(
'keyUp',
ctx => {
const event = ctx.get('keyboardState').raw;
if (event.code === 'Space' && !event.repeat) {
this._space(event);
}
},
{ global: true }
);
}
private _delete() {
const edgeless = this.rootComponent;
if (edgeless.service.locked) return;
if (edgeless.service.selection.editing) {
return;
}
const selectedElements = edgeless.service.selection.selectedElements;
if (selectedElements.some(e => e.isLocked())) return;
if (isSingleMindMapNode(selectedElements)) {
const node = selectedElements[0];
const mindmap = node.group as MindmapElementModel;
const focusNode =
mindmap.getSiblingNode(node.id, 'prev') ??
mindmap.getSiblingNode(node.id, 'next') ??
mindmap.getParentNode(node.id);
if (focusNode) {
edgeless.service.selection.set({
elements: [focusNode.element.id],
editing: false,
});
}
deleteElements(edgeless, selectedElements);
} else {
deleteElements(edgeless, selectedElements);
edgeless.service.selection.clear();
}
}
private _move(key: string, shift = false) {
const edgeless = this.rootComponent;
if (edgeless.service.locked) return;
if (edgeless.service.selection.editing) return;
const { selectedElements } = edgeless.service.selection;
const inc = shift ? 10 : 1;
const mindmapNodes = selectedElements.filter(
el => el.group instanceof MindmapElementModel
);
if (mindmapNodes.length > 0) {
const node = mindmapNodes[0];
const mindmap = node.group as MindmapElementModel;
const nodeDirection = mindmap.getLayoutDir(node.id);
let targetNode: GfxPrimitiveElementModel | null = null;
switch (key) {
case 'ArrowUp':
case 'ArrowDown':
targetNode =
mindmap.getSiblingNode(
node.id,
key === 'ArrowDown' ? 'next' : 'prev',
nodeDirection === LayoutType.RIGHT
? 'right'
: nodeDirection === LayoutType.LEFT
? 'left'
: undefined
)?.element ?? null;
break;
case 'ArrowLeft':
targetNode =
nodeDirection === LayoutType.RIGHT
? (mindmap.getParentNode(node.id)?.element ?? null)
: (mindmap.getChildNodes(node.id, 'left')[0]?.element ?? null);
break;
case 'ArrowRight':
targetNode =
nodeDirection === LayoutType.RIGHT ||
nodeDirection === LayoutType.BALANCE
? (mindmap.getChildNodes(node.id, 'right')[0]?.element ?? null)
: (mindmap.getParentNode(node.id)?.element ?? null);
break;
}
if (targetNode) {
edgeless.service.selection.set({
elements: [targetNode.id],
editing: false,
});
if (
isElementOutsideViewport(
edgeless.service.viewport,
targetNode,
[90, 20]
)
) {
const [dx, dy] = getNearestTranslation(
edgeless.service.viewport,
targetNode,
[100, 20]
);
edgeless.service.viewport.smoothTranslate(
edgeless.service.viewport.centerX - dx,
edgeless.service.viewport.centerY + dy
);
}
}
return;
}
if (selectedElements.some(e => e.isLocked())) return;
const movedElements = new Set([
...selectedElements,
...selectedElements
.map(el => (isGfxGroupCompatibleModel(el) ? el.descendantElements : []))
.flat(),
]);
movedElements.forEach(element => {
const bound = Bound.deserialize(element.xywh).clone();
switch (key) {
case 'ArrowUp':
bound.y -= inc;
break;
case 'ArrowLeft':
bound.x -= inc;
break;
case 'ArrowRight':
bound.x += inc;
break;
case 'ArrowDown':
bound.y += inc;
break;
}
if (isCanvasElement(element)) {
if (element instanceof ConnectorElementModel) {
element.moveTo(bound);
}
element['xywh'] = bound.serialize();
} else {
element['xywh'] = bound.serialize();
}
});
}
private _setEdgelessTool<K extends keyof GfxToolsMap>(
toolName: K,
...options: K extends keyof GfxToolsOption
? [option: GfxToolsOption[K], ignoreActiveState?: boolean]
: [option: void, ignoreActiveState?: boolean]
) {
const ignoreActiveState =
typeof options === 'boolean'
? options[0]
: options[1] === undefined
? false
: options[1];
// when editing, should not update mouse mode by shortcut
if (!ignoreActiveState && this.rootComponent.gfx.selection.editing) {
return;
}
this.rootComponent.gfx.tool.setTool<K>(
toolName,
// @ts-expect-error FIXME: ts error
options[0] !== undefined && typeof options[0] !== 'boolean'
? options[0]
: undefined
);
}
private _space(event: KeyboardEvent) {
/*
Call this function with a check for !event.repeat to consider only the first keydown (not repeat). This way, you can use onPressSpaceBar in a tool to determine if the space bar is pressed or not.
*/
const edgeless = this.rootComponent;
const selection = edgeless.service.selection;
const currentTool = edgeless.gfx.tool.currentTool$.peek()!;
const currentSel = selection.surfaceSelections;
const isKeyDown = event.type === 'keydown';
if (edgeless.gfx.tool.dragging$.peek()) {
return; // Don't do anything if currently dragging
}
const revertToPrevTool = (ev: KeyboardEvent) => {
if (ev.code === 'Space') {
this._setEdgelessTool(
// @ts-expect-error FIXME: ts error
currentTool.toolName,
currentTool?.activatedOption
);
selection.set(currentSel);
document.removeEventListener('keyup', revertToPrevTool, false);
}
};
if (isKeyDown) {
if (
currentTool.toolName === 'pan' ||
(currentTool.toolName === 'default' && selection.editing)
) {
return;
}
this._setEdgelessTool('pan', { panning: false });
edgeless.dispatcher.disposables.addFromEvent(
document,
'keyup',
revertToPrevTool
);
}
}
}

View File

@@ -0,0 +1,544 @@
import { NoteConfigExtension } from '@blocksuite/affine-block-note';
import type {
SurfaceBlockComponent,
SurfaceBlockModel,
} from '@blocksuite/affine-block-surface';
import {
EdgelessLegacySlotIdentifier,
getBgGridGap,
normalizeWheelDeltaY,
} from '@blocksuite/affine-block-surface';
import { isSingleMindMapNode } from '@blocksuite/affine-gfx-mindmap';
import { mountShapeTextEditor } from '@blocksuite/affine-gfx-shape';
import {
NoteBlockModel,
type RootBlockModel,
type ShapeElementModel,
} from '@blocksuite/affine-model';
import {
EditorSettingProvider,
EditPropsStore,
FontLoaderService,
ThemeProvider,
ViewportElementProvider,
} from '@blocksuite/affine-shared/services';
import {
isTouchPadPinchEvent,
matchModels,
requestConnectedFrame,
requestThrottledConnectedFrame,
} from '@blocksuite/affine-shared/utils';
import { IS_WINDOWS } from '@blocksuite/global/env';
import { Bound, Point, Vec } from '@blocksuite/global/gfx';
import {
BlockComponent,
type GfxBlockComponent,
SurfaceSelection,
type UIEventHandler,
} from '@blocksuite/std';
import {
GfxControllerIdentifier,
type GfxViewportElement,
} from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import { css, html } from 'lit';
import { query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { EdgelessRootBlockWidgetName } from '../types.js';
import type { EdgelessSelectedRectWidget } from './components/rects/edgeless-selected-rect.js';
import { EdgelessPageKeyboardManager } from './edgeless-keyboard.js';
import type { EdgelessRootService } from './edgeless-root-service.js';
import { isCanvasElement } from './utils/query.js';
export class EdgelessRootBlockComponent extends BlockComponent<
RootBlockModel,
EdgelessRootService,
EdgelessRootBlockWidgetName
> {
static override styles = css`
affine-edgeless-root {
-webkit-user-select: none;
user-select: none;
display: block;
height: 100%;
touch-action: none;
}
.widgets-container {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
contain: size layout;
height: 100%;
width: 100%;
}
.widgets-container > * {
pointer-events: auto;
}
.edgeless-background {
height: 100%;
background-color: var(--affine-background-primary-color);
background-image: radial-gradient(
var(--affine-edgeless-grid-color) 1px,
var(--affine-background-primary-color) 1px
);
}
.edgeless-container {
color: var(--affine-text-primary-color);
position: relative;
}
@media print {
.selected {
background-color: transparent !important;
}
}
`;
private readonly _refreshLayerViewport = requestThrottledConnectedFrame(
() => {
const { zoom, translateX, translateY } = this.gfx.viewport;
const gap = getBgGridGap(zoom);
if (this.backgroundElm) {
this.backgroundElm.style.setProperty(
'background-position',
`${translateX}px ${translateY}px`
);
this.backgroundElm.style.setProperty(
'background-size',
`${gap}px ${gap}px`
);
}
},
this
);
private _resizeObserver: ResizeObserver | null = null;
keyboardManager: EdgelessPageKeyboardManager | null = null;
get dispatcher() {
return this.std.event;
}
get fontLoader() {
return this.std.get(FontLoaderService);
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get selectedRectWidget() {
return this.host.view.getWidget(
'edgeless-selected-rect',
this.host.id
) as EdgelessSelectedRectWidget;
}
get slots() {
return this.std.get(EdgelessLegacySlotIdentifier);
}
get surfaceBlockModel() {
return this.model.children.find(
child => child.flavour === 'affine:surface'
) as SurfaceBlockModel;
}
get viewportElement(): HTMLElement {
return this.std.get(ViewportElementProvider).viewportElement;
}
private _initFontLoader() {
this.std
.get(FontLoaderService)
.ready.then(() => {
this.surface.refresh();
})
.catch(console.error);
}
private _initLayerUpdateEffect() {
const updateLayers = requestThrottledConnectedFrame(() => {
const blocks = Array.from(
this.gfxViewportElm.children as HTMLCollectionOf<GfxBlockComponent>
);
blocks.forEach((block: GfxBlockComponent) => {
block.updateZIndex?.();
});
});
this._disposables.add(
this.gfx.layer.slots.layerUpdated.subscribe(() => updateLayers())
);
}
private _initPanEvent() {
this.disposables.add(
this.dispatcher.add('pan', ctx => {
const { viewport } = this.gfx;
if (viewport.locked) return;
const multiPointersState = ctx.get('multiPointerState');
const [p1, p2] = multiPointersState.pointers;
const dx =
(0.25 * (p1.delta.x + p2.delta.x)) /
viewport.zoom /
viewport.viewScale;
const dy =
(0.25 * (p1.delta.y + p2.delta.y)) /
viewport.zoom /
viewport.viewScale;
// direction is opposite
viewport.applyDeltaCenter(-dx, -dy);
})
);
}
private _initPinchEvent() {
this.disposables.add(
this.dispatcher.add('pinch', ctx => {
const { viewport } = this.gfx;
if (viewport.locked) return;
const multiPointersState = ctx.get('multiPointerState');
const [p1, p2] = multiPointersState.pointers;
const currentCenter = new Point(
0.5 * (p1.x + p2.x),
0.5 * (p1.y + p2.y)
);
const lastDistance = Vec.dist(
[p1.x - p1.delta.x, p1.y - p1.delta.y],
[p2.x - p2.delta.x, p2.y - p2.delta.y]
);
const currentDistance = Vec.dist([p1.x, p1.y], [p2.x, p2.y]);
const zoom = (currentDistance / lastDistance) * viewport.zoom;
const [baseX, baseY] = viewport.toModelCoord(
currentCenter.x,
currentCenter.y
);
viewport.setZoom(zoom, new Point(baseX, baseY));
return false;
})
);
}
private _initPixelRatioChangeEffect() {
let media: MediaQueryList;
const onPixelRatioChange = () => {
if (media) {
this.gfx.viewport.onResize();
media.removeEventListener('change', onPixelRatioChange);
}
media = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
media.addEventListener('change', onPixelRatioChange);
};
onPixelRatioChange();
this._disposables.add(() => {
media?.removeEventListener('change', onPixelRatioChange);
});
}
private _initRemoteCursor() {
let rafId: number | null = null;
const setRemoteCursor = (pos: { x: number; y: number }) => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestConnectedFrame(() => {
if (!this.gfx.viewport) return;
const cursorPosition = this.gfx.viewport.toModelCoord(pos.x, pos.y);
this.gfx.selection.setCursor({
x: cursorPosition[0],
y: cursorPosition[1],
});
rafId = null;
}, this);
};
this.handleEvent('pointerMove', e => {
const pointerEvent = e.get('pointerState');
setRemoteCursor(pointerEvent);
});
}
private _initResizeEffect() {
const resizeObserver = new ResizeObserver((_: ResizeObserverEntry[]) => {
this.gfx.selection.set(this.gfx.selection.surfaceSelections);
this.gfx.viewport.onResize();
});
resizeObserver.observe(this.viewportElement);
this._resizeObserver = resizeObserver;
}
private _initSlotEffects() {
const { disposables } = this;
this.disposables.add(
this.std.get(ThemeProvider).theme$.subscribe(() => this.surface.refresh())
);
disposables.add(
effect(() => {
this.style.cursor = this.gfx.cursor$.value;
})
);
}
private _initViewport() {
const { std, gfx } = this;
const run = () => {
const animationFn = std.getOptional(
NoteConfigExtension.identifier
)?.pageBlockViewportFitAnimation;
if (animationFn) {
const pageBlock = this.model.children.find(
(child): child is NoteBlockModel =>
matchModels(child, [NoteBlockModel]) && child.isPageBlock()
);
if (pageBlock && animationFn({ std: this.std, note: pageBlock })) {
return;
}
}
const storedViewport = std.get(EditPropsStore).getStorage('viewport');
if (storedViewport) {
if ('xywh' in storedViewport) {
const bound = Bound.deserialize(storedViewport.xywh);
gfx.viewport.setViewportByBound(bound, storedViewport.padding);
} else {
const { zoom, centerX, centerY } = storedViewport;
gfx.viewport.setViewport(zoom, [centerX, centerY]);
}
return;
}
this.gfx.fitToScreen();
};
run();
this._disposables.add(() => {
std.get(EditPropsStore).setStorage('viewport', {
centerX: gfx.viewport.centerX,
centerY: gfx.viewport.centerY,
zoom: gfx.viewport.zoom,
});
});
}
private _initWheelEvent() {
this._disposables.add(
this.dispatcher.add('wheel', ctx => {
const config = this.std.getOptional(EditorSettingProvider);
const state = ctx.get('defaultState');
const e = state.event as WheelEvent;
const edgelessScrollZoom = config?.peek().edgelessScrollZoom ?? false;
e.preventDefault();
const { viewport } = this.gfx;
if (viewport.locked) return;
// zoom
if (isTouchPadPinchEvent(e) || edgelessScrollZoom) {
const rect = this.getBoundingClientRect();
// Perform zooming relative to the mouse position
const [baseX, baseY] = this.gfx.viewport.toModelCoord(
e.clientX - rect.x,
e.clientY - rect.y
);
const zoom = normalizeWheelDeltaY(e.deltaY, viewport.zoom);
viewport.setZoom(zoom, new Point(baseX, baseY), true);
e.stopPropagation();
}
// pan
else {
const simulateHorizontalScroll = IS_WINDOWS && e.shiftKey;
const dx = simulateHorizontalScroll
? e.deltaY / viewport.zoom
: e.deltaX / viewport.zoom;
const dy = simulateHorizontalScroll ? 0 : e.deltaY / viewport.zoom;
viewport.applyDeltaCenter(dx, dy);
viewport.viewportMoved.next([dx, dy]);
e.stopPropagation();
}
})
);
}
override bindHotKey(
keymap: Record<string, UIEventHandler>,
options?: { global?: boolean; flavour?: boolean }
): () => void {
const { gfx } = this;
const selection = gfx.selection;
Object.keys(keymap).forEach(key => {
if (key.length === 1 && key >= 'A' && key <= 'z') {
const handler = keymap[key];
keymap[key] = ctx => {
const elements = selection.selectedElements;
if (isSingleMindMapNode(elements) && !selection.editing) {
const target = gfx.getElementById(
elements[0].id
) as ShapeElementModel;
if (target.text) {
this.doc.transact(() => {
target.text!.delete(0, target.text!.length);
target.text!.insert(0, key);
});
}
mountShapeTextEditor(target, this);
} else {
handler(ctx);
}
};
}
});
return super.bindHotKey(keymap, options);
}
override connectedCallback() {
super.connectedCallback();
this._initViewport();
this.keyboardManager = new EdgelessPageKeyboardManager(this);
this.handleEvent('selectionChange', () => {
const surface = this.host.selection.value.find(
(sel): sel is SurfaceSelection => sel.is(SurfaceSelection)
);
if (!surface) return;
const el = this.gfx.getElementById(surface.elements[0]);
if (isCanvasElement(el)) {
return true;
}
return;
});
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = null;
}
this.keyboardManager = null;
}
override firstUpdated() {
this._initSlotEffects();
this._initResizeEffect();
this._initPixelRatioChangeEffect();
this._initFontLoader();
this._initRemoteCursor();
this._initLayerUpdateEffect();
this._initWheelEvent();
this._initPanEvent();
this._initPinchEvent();
if (this.doc.readonly) {
this.gfx.tool.setTool('pan', { panning: true });
} else {
this.gfx.tool.setTool('default');
}
this.gfx.viewport.elementReady.next(this.gfxViewportElm);
requestConnectedFrame(() => {
this.requestUpdate();
}, this);
this._disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(() => {
this._refreshLayerViewport();
})
);
this._refreshLayerViewport();
}
override renderBlock() {
const widgets = repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
);
return html`
<div class="edgeless-background edgeless-container">
<gfx-viewport
.maxConcurrentRenders=${6}
.viewport=${this.gfx.viewport}
.getModelsInViewport=${() => {
const blocks = this.gfx.grid.search(
this.gfx.viewport.viewportBounds,
{
useSet: true,
filter: ['block'],
}
);
return blocks;
}}
.host=${this.host}
>
${this.renderChildren(this.model)}
${this.renderChildren(this.surfaceBlockModel)}
</gfx-viewport>
</div>
<!--
Used to mount component before widgets
Eg., canvas text editor
-->
<div class="edgeless-mount-point"></div>
<div class="widgets-container">${widgets}</div>
`;
}
@query('.edgeless-background')
accessor backgroundElm: HTMLDivElement | null = null;
@query('gfx-viewport')
accessor gfxViewportElm!: GfxViewportElement;
@query('.edgeless-mount-point')
accessor mountElm: HTMLDivElement | null = null;
@query('affine-surface')
accessor surface!: SurfaceBlockComponent;
}

View File

@@ -0,0 +1,268 @@
import {
EdgelessCRUDIdentifier,
getBgGridGap,
type SurfaceBlockComponent,
type SurfaceBlockModel,
} from '@blocksuite/affine-block-surface';
import type { RootBlockModel } from '@blocksuite/affine-model';
import {
EditorSettingProvider,
FontLoaderService,
ThemeProvider,
ViewportElementProvider,
} from '@blocksuite/affine-shared/services';
import { requestThrottledConnectedFrame } from '@blocksuite/affine-shared/utils';
import {
BlockComponent,
type GfxBlockComponent,
SurfaceSelection,
} from '@blocksuite/std';
import {
GfxControllerIdentifier,
type GfxViewportElement,
} from '@blocksuite/std/gfx';
import { css, html } from 'lit';
import { query, state } from 'lit/decorators.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockWidgetName } from '../types.js';
import type { EdgelessRootService } from './edgeless-root-service.js';
import { isCanvasElement } from './utils/query.js';
export class EdgelessRootPreviewBlockComponent extends BlockComponent<
RootBlockModel,
EdgelessRootService,
EdgelessRootBlockWidgetName
> {
static override styles = css`
affine-edgeless-root-preview {
pointer-events: none;
-webkit-user-select: none;
user-select: none;
display: block;
height: 100%;
}
affine-edgeless-root-preview .widgets-container {
position: absolute;
left: 0;
top: 0;
contain: size layout;
z-index: 1;
height: 100%;
}
affine-edgeless-root-preview .edgeless-background {
height: 100%;
background-color: var(--affine-background-primary-color);
background-image: radial-gradient(
var(--affine-edgeless-grid-color) 1px,
var(--affine-background-primary-color) 1px
);
}
@media print {
.selected {
background-color: transparent !important;
}
}
`;
private readonly _refreshLayerViewport = requestThrottledConnectedFrame(
() => {
const { zoom, translateX, translateY } = this.service.viewport;
const gap = getBgGridGap(zoom);
this.backgroundStyle = {
backgroundPosition: `${translateX}px ${translateY}px`,
backgroundSize: `${gap}px ${gap}px`,
};
},
this
);
private _resizeObserver: ResizeObserver | null = null;
get dispatcher() {
return this.service?.uiEventDispatcher;
}
private get _gfx() {
return this.std.get(GfxControllerIdentifier);
}
get surfaceBlockModel() {
return this.model.children.find(
child => child.flavour === 'affine:surface'
) as SurfaceBlockModel;
}
get viewportElement(): HTMLElement {
return this.std.get(ViewportElementProvider).viewportElement;
}
private _initFontLoader() {
this.std
.get(FontLoaderService)
.ready.then(() => {
this.surface?.refresh();
})
.catch(console.error);
}
private _initLayerUpdateEffect() {
const updateLayers = requestThrottledConnectedFrame(() => {
const blocks = Array.from(
this.gfxViewportElm.children as HTMLCollectionOf<GfxBlockComponent>
);
blocks.forEach((block: GfxBlockComponent) => {
block.updateZIndex?.();
});
});
this._disposables.add(
this._gfx.layer.slots.layerUpdated.subscribe(() => updateLayers())
);
}
private _initPixelRatioChangeEffect() {
let media: MediaQueryList;
const onPixelRatioChange = () => {
if (media) {
this._gfx.viewport.onResize();
media.removeEventListener('change', onPixelRatioChange);
}
media = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
media.addEventListener('change', onPixelRatioChange);
};
onPixelRatioChange();
this._disposables.add(() => {
media?.removeEventListener('change', onPixelRatioChange);
});
}
private _initResizeEffect() {
const resizeObserver = new ResizeObserver((_: ResizeObserverEntry[]) => {
this._gfx.selection.set(this._gfx.selection.surfaceSelections);
this._gfx.viewport.onResize();
});
try {
resizeObserver.observe(this.viewportElement);
this._resizeObserver?.disconnect();
this._resizeObserver = resizeObserver;
} catch {
// viewport is not ready
console.error('Viewport is not ready');
}
}
private _initSlotEffects() {
this.disposables.add(
this.std
.get(ThemeProvider)
.theme$.subscribe(() => this.surface?.refresh())
);
}
private get _disableScheduleUpdate() {
const editorSetting = this.std.getOptional(EditorSettingProvider);
return editorSetting?.peek().edgelessDisableScheduleUpdate ?? false;
}
private get _crud() {
return this.std.get(EdgelessCRUDIdentifier);
}
override connectedCallback() {
super.connectedCallback();
this.handleEvent('selectionChange', () => {
const surface = this.host.selection.value.find(
(sel): sel is SurfaceSelection => sel.is(SurfaceSelection)
);
if (!surface) return;
const el = this._crud.getElementById(surface.elements[0]);
if (isCanvasElement(el)) {
return true;
}
return;
});
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = null;
}
}
override firstUpdated() {
this._initSlotEffects();
this._initResizeEffect();
this._initPixelRatioChangeEffect();
this._initFontLoader();
this._initLayerUpdateEffect();
this._disposables.add(
this._gfx.viewport.viewportUpdated.subscribe(() => {
this._refreshLayerViewport();
})
);
this._refreshLayerViewport();
}
override renderBlock() {
const background = styleMap({
...this.backgroundStyle,
background: this.overrideBackground,
});
return html`
<div class="edgeless-background edgeless-container" style=${background}>
<gfx-viewport
.enableChildrenSchedule=${!this._disableScheduleUpdate}
.viewport=${this._gfx.viewport}
.getModelsInViewport=${() => {
const blocks = this._gfx.grid.search(
this._gfx.viewport.viewportBounds,
{
useSet: true,
filter: ['block'],
}
);
return blocks;
}}
.host=${this.host}
>
${this.renderChildren(this.model)}${this.renderChildren(
this.surfaceBlockModel
)}
</gfx-viewport>
</div>
`;
}
@state()
accessor overrideBackground: string | undefined = undefined;
@state()
accessor backgroundStyle: Readonly<StyleInfo> | null = null;
@query('gfx-viewport')
accessor gfxViewportElm!: GfxViewportElement;
@query('affine-surface')
accessor surface!: SurfaceBlockComponent;
}

View File

@@ -0,0 +1,246 @@
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
import {
EdgelessCRUDIdentifier,
EdgelessLegacySlotIdentifier,
getSurfaceBlock,
type SurfaceBlockModel,
type SurfaceContext,
} from '@blocksuite/affine-block-surface';
import { TemplateJob } from '@blocksuite/affine-gfx-template';
import {
type ConnectorElementModel,
RootBlockSchema,
} from '@blocksuite/affine-model';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { BlockStdScope } from '@blocksuite/std';
import type {
GfxController,
GfxModel,
LayerManager,
PointTestOptions,
ReorderingDirection,
} from '@blocksuite/std/gfx';
import {
GfxBlockElementModel,
GfxControllerIdentifier,
isGfxGroupCompatibleModel,
ZOOM_MAX,
ZOOM_MIN,
ZOOM_STEP,
} from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import clamp from 'lodash-es/clamp';
import { RootService } from '../root-service.js';
import { getCursorMode } from './utils/query.js';
export class EdgelessRootService extends RootService implements SurfaceContext {
static override readonly flavour = RootBlockSchema.model.flavour;
private readonly _surface: SurfaceBlockModel;
TemplateJob = TemplateJob;
get blocks(): GfxBlockElementModel[] {
return this.layer.blocks;
}
/**
* sorted edgeless elements
*/
get edgelessElements(): GfxModel[] {
return [...this.layer.canvasElements, ...this.layer.blocks].sort(
this.layer.compare
);
}
/**
* sorted canvas elements
*/
get elements() {
return this.layer.canvasElements;
}
get frame() {
return this.std.get(EdgelessFrameManagerIdentifier);
}
/**
* Get all sorted frames by presentation orderer,
* the legacy frame that uses `index` as presentation order
* will be put at the beginning of the array.
*/
get frames() {
return this.frame.frames;
}
get gfx(): GfxController {
return this.std.get(GfxControllerIdentifier);
}
override get host() {
return this.std.host;
}
get layer(): LayerManager {
return this.gfx.layer;
}
get locked() {
return this.viewport.locked;
}
set locked(locked: boolean) {
this.viewport.locked = locked;
}
get selection() {
return this.gfx.selection;
}
get surface() {
return this._surface;
}
get viewport() {
return this.std.get(GfxControllerIdentifier).viewport;
}
get zoom() {
return this.viewport.zoom;
}
get crud() {
return this.std.get(EdgelessCRUDIdentifier);
}
constructor(std: BlockStdScope, flavourProvider: { flavour: string }) {
super(std, flavourProvider);
const surface = getSurfaceBlock(this.doc);
if (!surface) {
throw new BlockSuiteError(
ErrorCode.NoSurfaceModelError,
'This doc is missing surface block in edgeless.'
);
}
this._surface = surface;
}
private _initReadonlyListener() {
const doc = this.doc;
const slots = this.std.get(EdgelessLegacySlotIdentifier);
let readonly = doc.readonly;
this.disposables.add(
effect(() => {
if (readonly !== doc.readonly) {
readonly = doc.readonly;
slots.readonlyUpdated.next(readonly);
}
})
);
}
private _initSlotEffects() {
const { disposables } = this;
disposables.add(
effect(() => {
const value = this.gfx.tool.currentToolOption$.value;
this.gfx.cursor$.value = getCursorMode(value);
})
);
}
generateIndex() {
return this.layer.generateIndex();
}
getConnectors(element: GfxModel | string) {
const id = typeof element === 'string' ? element : element.id;
return this.surface.getConnectors(id) as ConnectorElementModel[];
}
override mounted() {
super.mounted();
this._initSlotEffects();
this._initReadonlyListener();
}
/**
* This method is used to pick element in group, if the picked element is in a
* group, we will pick the group instead. If that picked group is currently selected, then
* we will pick the element itself.
*/
pickElementInGroup(
x: number,
y: number,
options?: PointTestOptions
): GfxModel | null {
return this.gfx.getElementInGroup(x, y, options);
}
removeElement(id: string | GfxModel) {
id = typeof id === 'string' ? id : id.id;
const el = this.crud.getElementById(id);
if (isGfxGroupCompatibleModel(el)) {
el.childIds.forEach(childId => {
this.removeElement(childId);
});
}
if (el instanceof GfxBlockElementModel) {
this.doc.deleteBlock(el);
return;
}
if (this._surface.hasElementById(id)) {
this._surface.deleteElement(id);
return;
}
}
reorderElement(element: GfxModel, direction: ReorderingDirection) {
const index = this.layer.getReorderedIndex(element, direction);
// block should be updated in transaction
if (element instanceof GfxBlockElementModel) {
this.doc.transact(() => {
element.index = index;
});
} else {
element.index = index;
}
}
setZoomByAction(action: 'fit' | 'out' | 'reset' | 'in') {
if (this.locked) return;
switch (action) {
case 'fit':
this.gfx.fitToScreen();
break;
case 'reset':
this.viewport.smoothZoom(1.0);
break;
case 'in':
case 'out':
this.setZoomByStep(ZOOM_STEP * (action === 'in' ? 1 : -1));
}
}
setZoomByStep(step: number) {
this.viewport.smoothZoom(clamp(this.zoom + step, ZOOM_MIN, ZOOM_MAX));
}
override unmounted() {
super.unmounted();
this.viewport?.dispose();
this.selectionManager.set([]);
this.disposables.dispose();
}
}

View File

@@ -0,0 +1,117 @@
import { EdgelessClipboardAttachmentConfig } from '@blocksuite/affine-block-attachment';
import { EdgelessClipboardBookmarkConfig } from '@blocksuite/affine-block-bookmark';
import { EdgelessClipboardEdgelessTextConfig } from '@blocksuite/affine-block-edgeless-text';
import {
EdgelessClipboardEmbedFigmaConfig,
EdgelessClipboardEmbedGithubConfig,
EdgelessClipboardEmbedHtmlConfig,
EdgelessClipboardEmbedIframeConfig,
EdgelessClipboardEmbedLinkedDocConfig,
EdgelessClipboardEmbedLoomConfig,
EdgelessClipboardEmbedSyncedDocConfig,
EdgelessClipboardEmbedYoutubeConfig,
} from '@blocksuite/affine-block-embed';
import { EdgelessClipboardFrameConfig } from '@blocksuite/affine-block-frame';
import { EdgelessClipboardImageConfig } from '@blocksuite/affine-block-image';
import { EdgelessClipboardNoteConfig } from '@blocksuite/affine-block-note';
import { ViewportElementExtension } from '@blocksuite/affine-shared/services';
import { autoConnectWidget } from '@blocksuite/affine-widget-edgeless-auto-connect';
import { edgelessToolbarWidget } from '@blocksuite/affine-widget-edgeless-toolbar';
import { frameTitleWidget } from '@blocksuite/affine-widget-frame-title';
import { edgelessRemoteSelectionWidget } from '@blocksuite/affine-widget-remote-selection';
import {
BlockViewExtension,
LifeCycleWatcher,
WidgetViewExtension,
} from '@blocksuite/std';
import { GfxControllerIdentifier, ToolController } from '@blocksuite/std/gfx';
import type { ExtensionType } from '@blocksuite/store';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { CommonSpecs } from '../common-specs/index.js';
import { edgelessNavigatorBgWidget } from '../widgets/edgeless-navigator-bg/index.js';
import { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from '../widgets/edgeless-zoom-toolbar/index.js';
import { EdgelessClipboardController } from './clipboard/clipboard.js';
import { NOTE_SLICER_WIDGET } from './components/note-slicer/index.js';
import { EDGELESS_DRAGGING_AREA_WIDGET } from './components/rects/edgeless-dragging-area-rect.js';
import { EDGELESS_SELECTED_RECT_WIDGET } from './components/rects/edgeless-selected-rect.js';
import { quickTools, seniorTools } from './components/toolbar/tools.js';
import { EdgelessRootService } from './edgeless-root-service.js';
export const edgelessZoomToolbarWidget = WidgetViewExtension(
'affine:page',
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
literal`${unsafeStatic(AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET)}`
);
export const edgelessDraggingAreaWidget = WidgetViewExtension(
'affine:page',
EDGELESS_DRAGGING_AREA_WIDGET,
literal`${unsafeStatic(EDGELESS_DRAGGING_AREA_WIDGET)}`
);
export const noteSlicerWidget = WidgetViewExtension(
'affine:page',
NOTE_SLICER_WIDGET,
literal`${unsafeStatic(NOTE_SLICER_WIDGET)}`
);
export const edgelessSelectedRectWidget = WidgetViewExtension(
'affine:page',
EDGELESS_SELECTED_RECT_WIDGET,
literal`${unsafeStatic(EDGELESS_SELECTED_RECT_WIDGET)}`
);
class EdgelessLocker extends LifeCycleWatcher {
static override key = 'edgeless-locker';
override mounted() {
const { viewport } = this.std.get(GfxControllerIdentifier);
viewport.locked = true;
}
}
const EdgelessClipboardConfigs: ExtensionType[] = [
EdgelessClipboardNoteConfig,
EdgelessClipboardEdgelessTextConfig,
EdgelessClipboardImageConfig,
EdgelessClipboardFrameConfig,
EdgelessClipboardAttachmentConfig,
EdgelessClipboardBookmarkConfig,
EdgelessClipboardEmbedFigmaConfig,
EdgelessClipboardEmbedGithubConfig,
EdgelessClipboardEmbedHtmlConfig,
EdgelessClipboardEmbedLoomConfig,
EdgelessClipboardEmbedYoutubeConfig,
EdgelessClipboardEmbedIframeConfig,
EdgelessClipboardEmbedLinkedDocConfig,
EdgelessClipboardEmbedSyncedDocConfig,
];
const EdgelessCommonExtension: ExtensionType[] = [
CommonSpecs,
ToolController,
EdgelessRootService,
ViewportElementExtension('.affine-edgeless-viewport'),
...quickTools,
...seniorTools,
...EdgelessClipboardConfigs,
].flat();
export const EdgelessRootBlockSpec: ExtensionType[] = [
...EdgelessCommonExtension,
BlockViewExtension('affine:page', literal`affine-edgeless-root`),
edgelessRemoteSelectionWidget,
edgelessZoomToolbarWidget,
frameTitleWidget,
autoConnectWidget,
edgelessDraggingAreaWidget,
noteSlicerWidget,
edgelessNavigatorBgWidget,
edgelessSelectedRectWidget,
edgelessToolbarWidget,
EdgelessClipboardController,
];
export const PreviewEdgelessRootBlockSpec: ExtensionType[] = [
...EdgelessCommonExtension,
BlockViewExtension('affine:page', literal`affine-edgeless-root-preview`),
EdgelessLocker,
];

View File

@@ -0,0 +1,49 @@
import {
addText,
insertEdgelessTextCommand,
} from '@blocksuite/affine-gfx-text';
import {
FeatureFlagService,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { PointerEventState } from '@blocksuite/std';
import { TransformExtension } from '@blocksuite/std/gfx';
export class DblClickAddEdgelessText extends TransformExtension {
static override key = 'dbl-click-add-edgeless-text';
override dblClick(e: PointerEventState): void {
const textFlag = this.std.store
.get(FeatureFlagService)
.getFlag('enable_edgeless_text');
const picked = this.gfx.getElementByPoint(
...this.gfx.viewport.toModelCoord(e.x, e.y)
);
if (picked) {
return;
}
if (textFlag) {
const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y);
this.std.command.exec(insertEdgelessTextCommand, { x, y });
} else {
const edgelessView = this.std.view.getBlock(
this.std.store.root?.id || ''
);
if (edgelessView) {
addText(edgelessView, e);
}
}
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'canvas:dbclick',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'text',
});
return;
}
}

View File

@@ -0,0 +1,64 @@
import { OverlayIdentifier } from '@blocksuite/affine-block-surface';
import { MindmapElementModel } from '@blocksuite/affine-model';
import type { Bound } from '@blocksuite/global/gfx';
import {
type DragExtensionInitializeContext,
type ExtensionDragMoveContext,
type GfxModel,
TransformExtension,
} from '@blocksuite/std/gfx';
import type { SnapOverlay } from '../utils/snap-manager';
export class SnapExtension extends TransformExtension {
static override key = 'snap-manager';
get snapOverlay() {
return this.std.getOptional(
OverlayIdentifier('snap-manager')
) as SnapOverlay;
}
override onDragInitialize(initContext: DragExtensionInitializeContext) {
const snapOverlay = this.snapOverlay;
if (!snapOverlay) {
return {};
}
let alignBound: Bound;
return {
onDragStart() {
alignBound = snapOverlay.setMovingElements(
initContext.elements,
initContext.elements.reduce((pre, elem) => {
if (elem.group instanceof MindmapElementModel) {
pre.push(elem.group);
}
return pre;
}, [] as GfxModel[])
);
},
onDragMove(context: ExtensionDragMoveContext) {
if (
context.elements.length === 0 ||
alignBound.w === 0 ||
alignBound.h === 0
) {
return;
}
const currentBound = alignBound.moveDelta(context.dx, context.dy);
const alignRst = snapOverlay.align(currentBound);
context.dx = alignRst.dx + context.dx;
context.dy = alignRst.dy + context.dy;
},
onDragEnd() {
snapOverlay.clear();
},
};
}
}

View File

@@ -0,0 +1,14 @@
export enum DefaultModeDragType {
/** Moving connector label */
ConnectorLabelMoving = 'connector-label-moving',
/** Moving selected contents */
ContentMoving = 'content-moving',
/** Native range dragging inside active note block */
NativeEditing = 'native-editing',
/** Default void state */
None = 'none',
/** Dragging preview */
PreviewDragging = 'preview-dragging',
/** Expanding the dragging area, select the content covered inside */
Selecting = 'selecting',
}

View File

@@ -0,0 +1,561 @@
import {
type FrameOverlay,
isFrameBlock,
} from '@blocksuite/affine-block-frame';
import {
ConnectorUtils,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import {
type ConnectorElementModel,
GroupElementModel,
MindmapElementModel,
NoteBlockModel,
NoteDisplayMode,
} from '@blocksuite/affine-model';
import { resetNativeSelection } from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import type { IVec } from '@blocksuite/global/gfx';
import { Bound, getCommonBoundWithRotation, Vec } from '@blocksuite/global/gfx';
import type { BlockComponent, PointerEventState } from '@blocksuite/std';
import {
BaseTool,
getTopElements,
type GfxModel,
isGfxGroupCompatibleModel,
type PointTestOptions,
TransformManagerIdentifier,
} from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import { createElementsFromClipboardDataCommand } from '../clipboard/command.js';
import { prepareCloneData } from '../utils/clone-utils.js';
import { calPanDelta } from '../utils/panning-utils.js';
import { isCanvasElement } from '../utils/query.js';
import { DefaultModeDragType } from './default-tool-ext/ext.js';
export class DefaultTool extends BaseTool {
static override toolName: string = 'default';
private _accumulateDelta: IVec = [0, 0];
private _autoPanTimer: number | null = null;
private readonly _clearDisposable = () => {
if (this._disposables) {
this._disposables.dispose();
this._disposables = null;
}
};
private readonly _clearSelectingState = () => {
this._stopAutoPanning();
this._clearDisposable();
};
private _disposables: DisposableGroup | null = null;
private readonly _panViewport = (delta: IVec) => {
this._accumulateDelta[0] += delta[0];
this._accumulateDelta[1] += delta[1];
this.gfx.viewport.applyDeltaCenter(delta[0], delta[1]);
};
// For moving the connector label
private _selectedConnector: ConnectorElementModel | null = null;
private _selectedConnectorLabelBounds: Bound | null = null;
private _selectionRectTransition: null | {
w: number;
h: number;
startX: number;
startY: number;
endX: number;
endY: number;
} = null;
private readonly _startAutoPanning = (delta: IVec) => {
this._panViewport(delta);
this._updateSelectingState(delta);
this._stopAutoPanning();
this._autoPanTimer = window.setInterval(() => {
this._panViewport(delta);
this._updateSelectingState(delta);
}, 30);
};
private readonly _stopAutoPanning = () => {
if (this._autoPanTimer) {
clearTimeout(this._autoPanTimer);
this._autoPanTimer = null;
}
};
private _toBeMoved: GfxModel[] = [];
private readonly _updateSelectingState = (delta: IVec = [0, 0]) => {
const { gfx } = this;
if (gfx.keyboard.spaceKey$.peek() && this._selectionRectTransition) {
/* Move the selection if space is pressed */
const curDraggingViewArea = this.controller.draggingViewArea$.peek();
const { w, h, startX, startY, endX, endY } =
this._selectionRectTransition;
const { endX: lastX, endY: lastY } = curDraggingViewArea;
const dx = lastX + delta[0] - endX + this._accumulateDelta[0];
const dy = lastY + delta[1] - endY + this._accumulateDelta[1];
this.controller.draggingViewArea$.value = {
...curDraggingViewArea,
x: Math.min(startX + dx, lastX),
y: Math.min(startY + dy, lastY),
w,
h,
startX: startX + dx,
startY: startY + dy,
};
} else {
const curDraggingArea = this.controller.draggingViewArea$.peek();
const newStartX = curDraggingArea.startX - delta[0];
const newStartY = curDraggingArea.startY - delta[1];
this.controller.draggingViewArea$.value = {
...curDraggingArea,
startX: newStartX,
startY: newStartY,
x: Math.min(newStartX, curDraggingArea.endX),
y: Math.min(newStartY, curDraggingArea.endY),
w: Math.abs(curDraggingArea.endX - newStartX),
h: Math.abs(curDraggingArea.endY - newStartY),
};
}
const { x, y, w, h } = this.controller.draggingArea$.peek();
const bound = new Bound(x, y, w, h);
let elements = gfx.getElementsByBound(bound).filter(el => {
if (isFrameBlock(el)) {
return el.childElements.length === 0 || bound.contains(el.elementBound);
}
if (el instanceof MindmapElementModel) {
return bound.contains(el.elementBound);
}
if (
el instanceof NoteBlockModel &&
el.props.displayMode === NoteDisplayMode.DocOnly
) {
return false;
}
return true;
});
elements = getTopElements(elements).filter(el => !el.isLocked());
const set = new Set(
gfx.keyboard.shiftKey$.peek()
? [...elements, ...gfx.selection.selectedElements]
: elements
);
this.edgelessSelectionManager.set({
elements: Array.from(set).map(element => element.id),
editing: false,
});
};
dragType = DefaultModeDragType.None;
enableHover = true;
private get _edgeless(): BlockComponent | null {
return this.std.view.getBlock(this.doc.root!.id);
}
/**
* Get the end position of the dragging area in the model coordinate
*/
get dragLastPos() {
const { endX, endY } = this.controller.draggingArea$.peek();
return [endX, endY] as IVec;
}
/**
* Get the start position of the dragging area in the model coordinate
*/
get dragStartPos() {
const { startX, startY } = this.controller.draggingArea$.peek();
return [startX, startY] as IVec;
}
get edgelessSelectionManager() {
return this.gfx.selection;
}
get elementTransformMgr() {
return this.std.getOptional(TransformManagerIdentifier);
}
private get frameOverlay() {
return this.std.get(OverlayIdentifier('frame')) as FrameOverlay;
}
private async _cloneContent() {
if (!this._edgeless) return;
const snapshot = prepareCloneData(this._toBeMoved, this.std);
const bound = getCommonBoundWithRotation(this._toBeMoved);
const [_, { createdElementsPromise }] = this.std.command.exec(
createElementsFromClipboardDataCommand,
{
elementsRawData: snapshot,
pasteCenter: bound.center,
}
);
if (!createdElementsPromise) return;
const { canvasElements, blockModels } = await createdElementsPromise;
this._toBeMoved = [...canvasElements, ...blockModels];
this.edgelessSelectionManager.set({
elements: this._toBeMoved.map(e => e.id),
editing: false,
});
}
private _determineDragType(e: PointerEventState): DefaultModeDragType {
const { x, y } = e;
// Is dragging started from current selected rect
if (this.edgelessSelectionManager.isInSelectedRect(x, y)) {
if (this.edgelessSelectionManager.selectedElements.length === 1) {
let selected = this.edgelessSelectionManager.selectedElements[0];
// double check
const currentSelected = this._pick(x, y);
if (
!isFrameBlock(selected) &&
!(selected instanceof GroupElementModel) &&
currentSelected &&
currentSelected !== selected
) {
selected = currentSelected;
this.edgelessSelectionManager.set({
elements: [selected.id],
editing: false,
});
}
if (
isCanvasElement(selected) &&
ConnectorUtils.isConnectorWithLabel(selected) &&
(selected as ConnectorElementModel).labelIncludesPoint(
this.gfx.viewport.toModelCoord(x, y)
)
) {
this._selectedConnector = selected as ConnectorElementModel;
this._selectedConnectorLabelBounds = Bound.fromXYWH(
this._selectedConnector.labelXYWH!
);
return DefaultModeDragType.ConnectorLabelMoving;
}
}
return this.edgelessSelectionManager.editing
? DefaultModeDragType.NativeEditing
: DefaultModeDragType.ContentMoving;
} else {
const selected = this._pick(x, y);
if (selected) {
this.edgelessSelectionManager.set({
elements: [selected.id],
editing: false,
});
if (
isCanvasElement(selected) &&
ConnectorUtils.isConnectorWithLabel(selected) &&
(selected as ConnectorElementModel).labelIncludesPoint(
this.gfx.viewport.toModelCoord(x, y)
)
) {
this._selectedConnector = selected as ConnectorElementModel;
this._selectedConnectorLabelBounds = Bound.fromXYWH(
this._selectedConnector.labelXYWH!
);
return DefaultModeDragType.ConnectorLabelMoving;
}
return DefaultModeDragType.ContentMoving;
} else {
return DefaultModeDragType.Selecting;
}
}
}
private _moveLabel(delta: IVec) {
const connector = this._selectedConnector;
let bounds = this._selectedConnectorLabelBounds;
if (!connector || !bounds) return;
bounds = bounds.clone();
const center = connector.getNearestPoint(
Vec.add(bounds.center, delta) as IVec
);
const distance = connector.getOffsetDistanceByPoint(center as IVec);
bounds.center = center;
this.gfx.updateElement(connector, {
labelXYWH: bounds.toXYWH(),
labelOffset: {
distance,
},
});
}
private _pick(x: number, y: number, options?: PointTestOptions) {
const modelPos = this.gfx.viewport.toModelCoord(x, y);
const tryGetLockedAncestor = (e: GfxModel | null) => {
if (e?.isLockedByAncestor()) {
return e.groups.findLast(group => group.isLocked());
}
return e;
};
const result = this.gfx.getElementInGroup(
modelPos[0],
modelPos[1],
options
);
if (result instanceof MindmapElementModel) {
const picked = this.gfx.getElementByPoint(modelPos[0], modelPos[1], {
...((options ?? {}) as PointTestOptions),
all: true,
});
let pickedIdx = picked.length - 1;
while (pickedIdx >= 0) {
const element = picked[pickedIdx];
if (element === result) {
pickedIdx -= 1;
continue;
}
break;
}
return tryGetLockedAncestor(picked[pickedIdx]) ?? null;
}
return tryGetLockedAncestor(result);
}
private initializeDragState(
dragType: DefaultModeDragType,
event: PointerEventState
) {
this.dragType = dragType;
this._clearDisposable();
this._disposables = new DisposableGroup();
// If the drag type is selecting, set up the dragging area disposable group
// If the viewport updates when dragging, should update the dragging area and selection
if (this.dragType === DefaultModeDragType.Selecting) {
this._disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(() => {
if (
this.dragType === DefaultModeDragType.Selecting &&
this.controller.dragging$.peek() &&
!this._autoPanTimer
) {
this._updateSelectingState();
}
})
);
return;
}
if (this.dragType === DefaultModeDragType.ContentMoving) {
if (this.elementTransformMgr) {
this.doc.captureSync();
this.elementTransformMgr.initializeDrag({
movingElements: this._toBeMoved,
event: event.raw,
onDragEnd: () => {
this.doc.captureSync();
},
});
}
return;
}
}
override click(e: PointerEventState) {
if (this.doc.readonly) return;
if (!this.elementTransformMgr?.dispatchOnSelected(e)) {
this.edgelessSelectionManager.clear();
resetNativeSelection(null);
}
this.elementTransformMgr?.dispatch('click', e);
}
override deactivate() {
this._stopAutoPanning();
this._clearDisposable();
this._accumulateDelta = [0, 0];
}
override doubleClick(e: PointerEventState) {
if (this.doc.readonly) {
const viewport = this.gfx.viewport;
if (viewport.zoom === 1) {
this.gfx.fitToScreen();
} else {
// Zoom to 100% and Center
const [x, y] = viewport.toModelCoord(e.x, e.y);
viewport.setViewport(1, [x, y], true);
}
return;
}
this.elementTransformMgr?.dispatch('dblclick', e);
}
override dragEnd() {
if (this.edgelessSelectionManager.editing) return;
this.frameOverlay.clear();
this._toBeMoved = [];
this._selectedConnector = null;
this._selectedConnectorLabelBounds = null;
this._clearSelectingState();
this.dragType = DefaultModeDragType.None;
}
override dragMove(e: PointerEventState) {
const { viewport } = this.gfx;
switch (this.dragType) {
case DefaultModeDragType.Selecting: {
// Record the last drag pointer position for auto panning and view port updating
this._updateSelectingState();
const moveDelta = calPanDelta(viewport, e);
if (moveDelta) {
this._startAutoPanning(moveDelta);
} else {
this._stopAutoPanning();
}
break;
}
case DefaultModeDragType.ContentMoving: {
break;
}
case DefaultModeDragType.ConnectorLabelMoving: {
const dx = this.dragLastPos[0] - this.dragStartPos[0];
const dy = this.dragLastPos[1] - this.dragStartPos[1];
this._moveLabel([dx, dy]);
break;
}
case DefaultModeDragType.NativeEditing: {
// TODO reset if drag out of note
break;
}
}
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
override async dragStart(e: PointerEventState) {
if (this.edgelessSelectionManager.editing) return;
// Determine the drag type based on the current state and event
let dragType = this._determineDragType(e);
const elements = this.edgelessSelectionManager.selectedElements;
if (elements.some(e => e.isLocked())) return;
const toBeMoved = new Set(elements);
elements.forEach(element => {
if (isGfxGroupCompatibleModel(element)) {
element.descendantElements.forEach(ele => {
toBeMoved.add(ele);
});
}
});
this._toBeMoved = Array.from(toBeMoved);
// If alt key is pressed and content is moving, clone the content
if (dragType === DefaultModeDragType.ContentMoving && e.keys.alt) {
await this._cloneContent();
}
// Set up drag state
this.initializeDragState(dragType, e);
}
override mounted() {
this.disposable.add(
effect(() => {
const pressed = this.gfx.keyboard.spaceKey$.value;
if (pressed) {
const currentDraggingArea = this.controller.draggingViewArea$.peek();
this._selectionRectTransition = {
w: currentDraggingArea.w,
h: currentDraggingArea.h,
startX: currentDraggingArea.startX,
startY: currentDraggingArea.startY,
endX: currentDraggingArea.endX,
endY: currentDraggingArea.endY,
};
} else {
this._selectionRectTransition = null;
}
})
);
}
override pointerDown(e: PointerEventState): void {
this.elementTransformMgr?.dispatch('pointerdown', e);
}
override pointerMove(e: PointerEventState) {
const hovered = this._pick(e.x, e.y, {
hitThreshold: 10,
});
if (
isFrameBlock(hovered) &&
hovered.externalBound?.isPointInBound(
this.gfx.viewport.toModelCoord(e.x, e.y)
)
) {
this.frameOverlay.highlight(hovered);
} else {
this.frameOverlay.clear();
}
this.elementTransformMgr?.dispatch('pointermove', e);
}
override pointerUp(e: PointerEventState) {
this.elementTransformMgr?.dispatch('pointerup', e);
}
override tripleClick() {}
override unmounted(): void {}
}
declare module '@blocksuite/std/gfx' {
interface GfxToolsMap {
default: DefaultTool;
}
}

View File

@@ -0,0 +1,14 @@
import { BaseTool } from '@blocksuite/std/gfx';
/**
* Empty tool that does nothing.
*/
export class EmptyTool extends BaseTool {
static override toolName: string = 'empty';
}
declare module '@blocksuite/std/gfx' {
interface GfxToolsMap {
empty: EmptyTool;
}
}

View File

@@ -0,0 +1,4 @@
export { DefaultTool } from './default-tool.js';
export { EmptyTool } from './empty-tool.js';
export { PanTool, type PanToolOption } from './pan-tool.js';
export { TemplateTool } from './template-tool.js';

View File

@@ -0,0 +1,87 @@
import { on } from '@blocksuite/affine-shared/utils';
import type { PointerEventState } from '@blocksuite/std';
import { BaseTool, MouseButton } from '@blocksuite/std/gfx';
import { Signal } from '@preact/signals-core';
export type PanToolOption = {
panning: boolean;
};
export class PanTool extends BaseTool<PanToolOption> {
static override toolName = 'pan';
private _lastPoint: [number, number] | null = null;
readonly panning$ = new Signal<boolean>(false);
override get allowDragWithRightButton(): boolean {
return true;
}
override dragEnd(_: PointerEventState): void {
this._lastPoint = null;
this.panning$.value = false;
}
override dragMove(e: PointerEventState): void {
if (!this._lastPoint) return;
const { viewport } = this.gfx;
const { zoom } = viewport;
const [lastX, lastY] = this._lastPoint;
const deltaX = lastX - e.x;
const deltaY = lastY - e.y;
this._lastPoint = [e.x, e.y];
viewport.applyDeltaCenter(deltaX / zoom, deltaY / zoom);
}
override dragStart(e: PointerEventState): void {
this._lastPoint = [e.x, e.y];
this.panning$.value = true;
}
override mounted(): void {
this.addHook('pointerDown', evt => {
const shouldPanWithMiddle = evt.raw.button === MouseButton.MIDDLE;
if (!shouldPanWithMiddle) {
return;
}
evt.raw.preventDefault();
const selection = this.gfx.selection.surfaceSelections;
const currentTool = this.controller.currentToolOption$.peek();
const restoreToPrevious = () => {
this.controller.setTool(currentTool);
this.gfx.selection.set(selection);
};
this.controller.setTool('pan', {
panning: true,
});
const dispose = on(document, 'pointerup', evt => {
if (evt.button === MouseButton.MIDDLE) {
restoreToPrevious();
dispose();
}
});
return false;
});
}
}
declare module '@blocksuite/std/gfx' {
interface GfxToolsMap {
pan: PanTool;
}
interface GfxToolsOption {
pan: PanToolOption;
}
}

View File

@@ -0,0 +1,11 @@
import { BaseTool } from '@blocksuite/std/gfx';
export class TemplateTool extends BaseTool {
static override toolName: string = 'template';
}
declare module '@blocksuite/std/gfx' {
interface GfxToolsMap {
template: TemplateTool;
}
}

View File

@@ -0,0 +1,10 @@
export * from './clipboard/clipboard';
export * from './clipboard/command';
export * from './edgeless-root-block.js';
export { EdgelessRootPreviewBlockComponent } from './edgeless-root-preview-block.js';
export { EdgelessRootService } from './edgeless-root-service.js';
export * from './gfx-tool';
export * from './utils/clipboard-utils.js';
export { sortEdgelessElements } from './utils/clone-utils.js';
export { isCanvasElement } from './utils/query.js';
export { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';

View File

@@ -0,0 +1,25 @@
import { getLastPropsKey } from '@blocksuite/affine-block-surface';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import {
type SurfaceMiddleware,
SurfaceMiddlewareBuilder,
} from '@blocksuite/std/gfx';
export class EditPropsMiddlewareBuilder extends SurfaceMiddlewareBuilder {
static override key = 'editProps';
middleware: SurfaceMiddleware = ctx => {
if (ctx.type === 'beforeAdd') {
const { type, props } = ctx.payload;
const key = getLastPropsKey(type, props);
const nProps = key
? this.std.get(EditPropsStore).applyLastProps(key, ctx.payload.props)
: null;
ctx.payload.props = {
...(nProps ?? props),
index: props.index ?? this.gfx.layer.generateIndex(),
};
}
};
}

View File

@@ -0,0 +1,97 @@
import { isFrameBlock } from '@blocksuite/affine-block-frame';
import {
getSurfaceComponent,
isNoteBlock,
} from '@blocksuite/affine-block-surface';
import type {
EdgelessTextBlockModel,
EmbedSyncedDocModel,
FrameBlockModel,
ImageBlockModel,
NoteBlockModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import { getElementsWithoutGroup } from '@blocksuite/affine-shared/utils';
import { getCommonBoundWithRotation } from '@blocksuite/global/gfx';
import type { BlockComponent } from '@blocksuite/std';
import { GfxControllerIdentifier, type GfxModel } from '@blocksuite/std/gfx';
import groupBy from 'lodash-es/groupBy';
import { createElementsFromClipboardDataCommand } from '../clipboard/command.js';
import { getSortedCloneElements, prepareCloneData } from './clone-utils.js';
import {
isEdgelessTextBlock,
isEmbedSyncedDocBlock,
isImageBlock,
} from './query.js';
const offset = 10;
export async function duplicate(
edgeless: BlockComponent,
elements: GfxModel[],
select = true
) {
const gfx = edgeless.std.get(GfxControllerIdentifier);
const surface = getSurfaceComponent(edgeless.std);
if (!surface) return;
const copyElements = getSortedCloneElements(elements);
const totalBound = getCommonBoundWithRotation(copyElements);
totalBound.x += totalBound.w + offset;
const snapshot = prepareCloneData(copyElements, edgeless.std);
const [_, { createdElementsPromise }] = edgeless.std.command.exec(
createElementsFromClipboardDataCommand,
{
elementsRawData: snapshot,
pasteCenter: totalBound.center,
}
);
if (!createdElementsPromise) return;
const { canvasElements, blockModels } = await createdElementsPromise;
const newElements = [...canvasElements, ...blockModels];
surface.fitToViewport(totalBound);
if (select) {
gfx.selection.set({
elements: newElements.map(e => e.id),
editing: false,
});
}
}
export const splitElements = (elements: GfxModel[]) => {
const { notes, frames, shapes, images, edgelessTexts, embedSyncedDocs } =
groupBy(getElementsWithoutGroup(elements), element => {
if (isNoteBlock(element)) {
return 'notes';
} else if (isFrameBlock(element)) {
return 'frames';
} else if (isImageBlock(element)) {
return 'images';
} else if (isEdgelessTextBlock(element)) {
return 'edgelessTexts';
} else if (isEmbedSyncedDocBlock(element)) {
return 'embedSyncedDocs';
}
return 'shapes';
}) as {
notes: NoteBlockModel[];
shapes: ShapeElementModel[];
frames: FrameBlockModel[];
images: ImageBlockModel[];
edgelessTexts: EdgelessTextBlockModel[];
embedSyncedDocs: EmbedSyncedDocModel[];
};
return {
notes: notes ?? [],
shapes: shapes ?? [],
frames: frames ?? [],
images: images ?? [],
edgelessTexts: edgelessTexts ?? [],
embedSyncedDocs: embedSyncedDocs ?? [],
};
};

View File

@@ -0,0 +1,237 @@
import type {
FrameBlockProps,
NodeDetail,
SerializedConnectorElement,
SerializedGroupElement,
SerializedMindmapElement,
} from '@blocksuite/affine-model';
import {
ConnectorElementModel,
GroupElementModel,
MindmapElementModel,
} from '@blocksuite/affine-model';
import type { BlockStdScope } from '@blocksuite/std';
import {
getTopElements,
GfxBlockElementModel,
type GfxModel,
type GfxPrimitiveElementModel,
isGfxGroupCompatibleModel,
type SerializedElement,
} from '@blocksuite/std/gfx';
import type { BlockSnapshot, Transformer } from '@blocksuite/store';
/**
* return all elements in the tree of the elements
*/
export function getSortedCloneElements(elements: GfxModel[]) {
const set = new Set<GfxModel>();
elements.forEach(element => {
// this element subtree has been added
if (set.has(element)) return;
set.add(element);
if (isGfxGroupCompatibleModel(element)) {
element.descendantElements.forEach(descendant => set.add(descendant));
}
});
return sortEdgelessElements([...set]);
}
export function prepareCloneData(elements: GfxModel[], std: BlockStdScope) {
elements = sortEdgelessElements(elements);
const job = std.store.getTransformer();
const res = elements.map(element => {
const data = serializeElement(element, elements, job);
return data;
});
return res.filter((d): d is SerializedElement | BlockSnapshot => !!d);
}
export function serializeElement(
element: GfxModel,
elements: GfxModel[],
job: Transformer
) {
if (element instanceof GfxBlockElementModel) {
const snapshot = job.blockToSnapshot(element);
if (!snapshot) {
return;
}
return { ...snapshot };
} else if (element instanceof ConnectorElementModel) {
return serializeConnector(element, elements);
} else {
return element.serialize();
}
}
export function serializeConnector(
connector: ConnectorElementModel,
elements: GfxModel[]
) {
const sourceId = connector.source?.id;
const targetId = connector.target?.id;
const serialized = connector.serialize();
// if the source or target element not to be cloned
// transfer connector position to absolute path
if (sourceId && elements.every(s => s.id !== sourceId)) {
serialized.source = { position: connector.absolutePath[0] };
}
if (targetId && elements.every(s => s.id !== targetId)) {
serialized.target = {
position: connector.absolutePath[connector.absolutePath.length - 1],
};
}
return serialized;
}
/**
* There are interdependencies between elements,
* so they must be added in a certain order
* @param elements edgeless model list
* @returns sorted edgeless model list
*/
export function sortEdgelessElements(elements: GfxModel[]) {
// Since each element has a parent-child relationship, and from-to connector relationship
// the child element must be added before the parent element
// and the connected elements must be added before the connector element
// To achieve this, we do a post-order traversal of the tree
if (elements.length === 0) return [];
const result: GfxModel[] = [];
const topElements = getTopElements(elements);
// the connector element must be added after the connected elements
const moveConnectorToEnd = (elements: GfxModel[]) => {
const connectors = elements.filter(
element => element instanceof ConnectorElementModel
);
const rest = elements.filter(
element => !(element instanceof ConnectorElementModel)
);
return [...rest, ...connectors];
};
const traverse = (element: GfxModel) => {
if (isGfxGroupCompatibleModel(element)) {
moveConnectorToEnd(element.childElements).forEach(child =>
traverse(child)
);
}
result.push(element);
};
moveConnectorToEnd(topElements).forEach(element => traverse(element));
return result;
}
/**
* map connector source & target ids
* @param props serialized element props
* @param ids old element id to new element id map
* @returns updated element props
*/
export function mapConnectorIds(
props: SerializedConnectorElement,
ids: Map<string, string>
) {
if (props.source.id) {
props.source.id = ids.get(props.source.id);
}
if (props.target.id) {
props.target.id = ids.get(props.target.id);
}
return props;
}
/**
* map group children ids
* @param props serialized element props
* @param ids old element id to new element id map
* @returns updated element props
*/
export function mapGroupIds(
props: SerializedGroupElement,
ids: Map<string, string>
) {
if (props.children) {
const newMap: Record<string, boolean> = {};
for (const [key, value] of Object.entries(props.children)) {
const newKey = ids.get(key);
if (newKey) {
newMap[newKey] = value;
}
}
props.children = newMap;
}
return props;
}
/**
* map frame children ids
* @param props frame block props
* @param ids old element id to new element id map
* @returns updated frame block props
*/
export function mapFrameIds(props: FrameBlockProps, ids: Map<string, string>) {
const oldChildIds = props.childElementIds
? Object.keys(props.childElementIds)
: [];
const newChildIds: Record<string, boolean> = {};
oldChildIds.forEach(oldId => {
const newIds = ids.get(oldId);
if (newIds) newChildIds[newIds] = true;
});
props.childElementIds = newChildIds;
return props;
}
/**
* map mindmap children & parent ids
* @param props serialized element props
* @param ids old element id to new element id map
* @returns updated element props
*/
export function mapMindmapIds(
props: SerializedMindmapElement,
ids: Map<string, string>
) {
if (props.children) {
const newMap: Record<string, NodeDetail> = {};
for (const [key, value] of Object.entries(props.children)) {
const newKey = ids.get(key);
if (value.parent) {
const newParent = ids.get(value.parent);
value.parent = newParent;
}
if (newKey) {
newMap[newKey] = value;
}
}
props.children = newMap;
}
return props;
}
export function getElementProps(
element: GfxPrimitiveElementModel,
ids: Map<string, string>
) {
if (element instanceof ConnectorElementModel) {
const props = element.serialize();
return mapConnectorIds(props, ids);
}
if (element instanceof GroupElementModel) {
const props = element.serialize();
return mapGroupIds(props, ids);
}
if (element instanceof MindmapElementModel) {
const props = element.serialize();
return mapMindmapIds(props, ids);
}
return element.serialize();
}

View File

@@ -0,0 +1,30 @@
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type { EdgelessRootService } from '../edgeless-root-service.js';
/**
* move connectors from origin to target
* @param originId origin element id
* @param targetId target element id
* @param service edgeless root service
*/
export function moveConnectors(
originId: string,
targetId: string,
service: EdgelessRootService
) {
const connectors = service.surface.getConnectors(originId);
const crud = service.std.get(EdgelessCRUDIdentifier);
connectors.forEach(connector => {
if (connector.source.id === originId) {
crud.updateElement(connector.id, {
source: { ...connector.source, id: targetId },
});
}
if (connector.target.id === originId) {
crud.updateElement(connector.id, {
target: { ...connector.target, id: targetId },
});
}
});
}

View File

@@ -0,0 +1,21 @@
export const DEFAULT_NOTE_CHILD_FLAVOUR = 'affine:paragraph';
export const DEFAULT_NOTE_CHILD_TYPE = 'text';
export const DEFAULT_NOTE_TIP = 'Text';
export const FIT_TO_SCREEN_PADDING = 100;
export const ATTACHED_DISTANCE = 20;
export const SurfaceColor = '#6046FE';
export const NoteColor = '#1E96EB';
export const BlendColor = '#7D91FF';
export const AI_CHAT_BLOCK_MIN_WIDTH = 260;
export const AI_CHAT_BLOCK_MIN_HEIGHT = 160;
export const AI_CHAT_BLOCK_MAX_WIDTH = 320;
export const AI_CHAT_BLOCK_MAX_HEIGHT = 300;
export const EMBED_IFRAME_BLOCK_MIN_WIDTH = 218;
export const EMBED_IFRAME_BLOCK_MIN_HEIGHT = 44;
export const EMBED_IFRAME_BLOCK_MAX_WIDTH = 3400;
export const EMBED_IFRAME_BLOCK_MAX_HEIGHT = 2200;

View File

@@ -0,0 +1,37 @@
import { isNoteBlock } from '@blocksuite/affine-block-surface';
import type { Connectable } from '@blocksuite/affine-model';
import type { GfxModel } from '@blocksuite/std/gfx';
import type { EdgelessRootBlockComponent } from '../index.js';
import { isConnectable } from './query.js';
/**
* Use deleteElementsV2 instead.
* @deprecated
*/
export function deleteElements(
edgeless: EdgelessRootBlockComponent,
elements: GfxModel[]
) {
const set = new Set(elements);
const { service } = edgeless;
elements.forEach(element => {
if (isConnectable(element)) {
const connectors = service.getConnectors(element as Connectable);
connectors.forEach(connector => set.add(connector));
}
});
set.forEach(element => {
if (isNoteBlock(element)) {
const children = edgeless.doc.root?.children ?? [];
// FIXME: should always keep at least 1 note
if (children.length > 1) {
edgeless.doc.deleteBlock(element);
}
} else {
service.removeElement(element.id);
}
});
}

View File

@@ -0,0 +1,2 @@
// TODO(@fundon): move to pen module
export const drawingCursor = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cg filter='url(%23filter0_d_5033_225305)'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16.138 6.9046C16.6785 6.36513 17.5553 6.36513 18.0958 6.9046C18.6358 7.44353 18.6358 8.31689 18.0958 8.85582L17.3186 9.63134L15.3621 7.67873L16.138 6.9046ZM14.6542 8.38506L16.6107 10.3377L8.96075 17.9707L6.61523 18.384L6.94908 16.461C7.00206 16.1558 7.14823 15.8745 7.36749 15.6557L14.6542 8.38506Z' fill='black'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M18.095 6.9046C17.5545 6.36513 16.6777 6.36513 16.1372 6.9046L15.3613 7.67873L17.3178 9.63134L18.095 8.85582C18.635 8.31689 18.635 7.44353 18.095 6.9046ZM18.8014 9.56366C19.7328 8.63405 19.7329 7.12641 18.8014 6.1968C17.8705 5.26773 16.3616 5.26773 15.4307 6.1968L6.66035 14.9478C6.29491 15.3124 6.05131 15.7813 5.96301 16.2899L5.50738 18.9145C5.47951 19.075 5.53158 19.239 5.6469 19.354C5.76223 19.469 5.92636 19.5207 6.08678 19.4924L9.28847 18.9282C9.38935 18.9104 9.48233 18.8621 9.55485 18.7898L17.671 10.6918L18.8014 9.56366ZM16.6099 10.3377L14.6534 8.38506L7.36668 15.6557C7.14741 15.8745 7.00125 16.1558 6.94827 16.461L6.61442 18.384L8.95993 17.9707L16.6099 10.3377Z' fill='white'/%3E%3C/g%3E%3Cdefs%3E%3Cfilter id='filter0_d_5033_225305' x='-1.8' y='-0.8' width='27.6' height='27.6' filterUnits='userSpaceOnUse' color-interpolation-filters='sRGB'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeColorMatrix 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'/%3E%3CfeOffset dy='1'/%3E%3CfeGaussianBlur stdDeviation='0.9'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0'/%3E%3CfeBlend mode='normal' in2='BackgroundImageFix' result='effect1_dropShadow_5033_225305'/%3E%3CfeBlend mode='normal' in='SourceGraphic' in2='effect1_dropShadow_5033_225305' result='shape'/%3E%3C/filter%3E%3C/defs%3E%3C/svg%3E") 4 20, crosshair`;

View File

@@ -0,0 +1,15 @@
import type { ShapeToolOption } from '@blocksuite/affine-gfx-shape';
import { ShapeType } from '@blocksuite/affine-model';
const shapeMap: Record<ShapeToolOption['shapeName'], number> = {
[ShapeType.Rect]: 0,
[ShapeType.Ellipse]: 1,
[ShapeType.Diamond]: 2,
[ShapeType.Triangle]: 3,
roundedRect: 4,
};
const shapes = Object.keys(shapeMap) as ShapeToolOption['shapeName'][];
export function getNextShapeType(cur: ShapeToolOption['shapeName']) {
return shapes[(shapeMap[cur] + 1) % shapes.length];
}

View File

@@ -0,0 +1,46 @@
import type { IVec } from '@blocksuite/global/gfx';
import type { PointerEventState } from '@blocksuite/std';
import type { Viewport } from '@blocksuite/std/gfx';
const PANNING_DISTANCE = 30;
export function calPanDelta(
viewport: Viewport,
e: PointerEventState,
edgeDistance = 20
): IVec | null {
// Get viewport edge
const { left, top } = viewport;
const { width, height } = viewport;
// Get pointer position
let { x, y } = e;
const { containerOffset } = e;
x += containerOffset.x;
y += containerOffset.y;
// Check if pointer is near viewport edge
const nearLeft = x < left + edgeDistance;
const nearRight = x > left + width - edgeDistance;
const nearTop = y < top + edgeDistance;
const nearBottom = y > top + height - edgeDistance;
// If pointer is not near viewport edge, return false
if (!(nearLeft || nearRight || nearTop || nearBottom)) return null;
// Calculate move delta
let deltaX = 0;
let deltaY = 0;
// Use PANNING_DISTANCE to limit the max delta, avoid panning too fast
if (nearLeft) {
deltaX = Math.max(-PANNING_DISTANCE, x - (left + edgeDistance));
} else if (nearRight) {
deltaX = Math.min(PANNING_DISTANCE, x - (left + width - edgeDistance));
}
if (nearTop) {
deltaY = Math.max(-PANNING_DISTANCE, y - (top + edgeDistance));
} else if (nearBottom) {
deltaY = Math.min(PANNING_DISTANCE, y - (top + height - edgeDistance));
}
return [deltaX, deltaY];
}

View File

@@ -0,0 +1,258 @@
import type { CanvasElementWithText } from '@blocksuite/affine-block-surface';
import {
type AttachmentBlockModel,
type BookmarkBlockModel,
type Connectable,
ConnectorElementModel,
type EdgelessTextBlockModel,
type EmbedBlockModel,
type EmbedFigmaModel,
type EmbedGithubModel,
type EmbedHtmlModel,
type EmbedLinkedDocModel,
type EmbedLoomModel,
type EmbedSyncedDocModel,
type EmbedYoutubeModel,
type ImageBlockModel,
ShapeElementModel,
TextElementModel,
} from '@blocksuite/affine-model';
import {
getElementsWithoutGroup,
isTopLevelBlock,
} from '@blocksuite/affine-shared/utils';
import type { PointLocation } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
import type {
GfxModel,
GfxPrimitiveElementModel,
GfxToolsFullOptionValue,
Viewport,
} from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import { drawingCursor } from './cursors';
export function isEdgelessTextBlock(
element: BlockModel | GfxModel | null
): element is EdgelessTextBlockModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:edgeless-text'
);
}
export function isImageBlock(
element: BlockModel | GfxModel | null
): element is ImageBlockModel {
return (
!!element && 'flavour' in element && element.flavour === 'affine:image'
);
}
export function isAttachmentBlock(
element: BlockModel | GfxModel | null
): element is AttachmentBlockModel {
return (
!!element && 'flavour' in element && element.flavour === 'affine:attachment'
);
}
export function isBookmarkBlock(
element: BlockModel | GfxModel | null
): element is BookmarkBlockModel {
return (
!!element && 'flavour' in element && element.flavour === 'affine:bookmark'
);
}
export function isEmbeddedBlock(
element: BlockModel | GfxModel | null
): element is EmbedBlockModel {
return (
!!element && 'flavour' in element && /affine:embed-*/.test(element.flavour)
);
}
/**
* TODO: Remove this function after the edgeless refactor completed
* This function is used to check if the block is an AI chat block for edgeless selected rect
* Should not be used in the future
* Related issue: https://linear.app/affine-design/issue/BS-1009/
* @deprecated
*/
export function isAIChatBlock(element: BlockModel | GfxModel | null) {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-ai-chat'
);
}
/**
* TODO: Remove this function after the edgeless refactor completed
* This function is used to check if the block is an EmbedIframeBlock for edgeless selected rect
* Should not be used in the future
* Related issue: https://linear.app/affine-design/issue/BS-2841/
* @deprecated
*/
export function isEmbedIframeBlock(element: BlockModel | GfxModel | null) {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-iframe'
);
}
export function isEmbeddedLinkBlock(element: BlockModel | GfxModel | null) {
return (
isEmbeddedBlock(element) &&
!isEmbedSyncedDocBlock(element) &&
!isEmbedLinkedDocBlock(element)
);
}
export function isEmbedGithubBlock(
element: BlockModel | GfxModel | null
): element is EmbedGithubModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-github'
);
}
export function isEmbedYoutubeBlock(
element: BlockModel | GfxModel | null
): element is EmbedYoutubeModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-youtube'
);
}
export function isEmbedLoomBlock(
element: BlockModel | GfxModel | null
): element is EmbedLoomModel {
return (
!!element && 'flavour' in element && element.flavour === 'affine:embed-loom'
);
}
export function isEmbedFigmaBlock(
element: BlockModel | GfxModel | null
): element is EmbedFigmaModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-figma'
);
}
export function isEmbedLinkedDocBlock(
element: BlockModel | GfxModel | null
): element is EmbedLinkedDocModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-linked-doc'
);
}
export function isEmbedSyncedDocBlock(
element: BlockModel | GfxModel | null
): element is EmbedSyncedDocModel {
return (
!!element &&
'flavour' in element &&
element.flavour === 'affine:embed-synced-doc'
);
}
export function isEmbedHtmlBlock(
element: BlockModel | GfxModel | null
): element is EmbedHtmlModel {
return (
!!element && 'flavour' in element && element.flavour === 'affine:embed-html'
);
}
export function isCanvasElement(
selectable: GfxModel | BlockModel | null
): selectable is GfxPrimitiveElementModel {
return !isTopLevelBlock(selectable);
}
export function isCanvasElementWithText(
element: GfxModel
): element is CanvasElementWithText {
return (
element instanceof TextElementModel || element instanceof ShapeElementModel
);
}
export function isConnectable(
element: GfxModel | null
): element is Connectable {
return !!element && element.connectable;
}
export function getSelectionBoxBound(viewport: Viewport, bound: Bound) {
const { w, h } = bound;
const [x, y] = viewport.toViewCoord(bound.x, bound.y);
return new DOMRect(x, y, w * viewport.zoom, h * viewport.zoom);
}
// https://developer.mozilla.org/en-US/docs/Web/CSS/cursor
export function getCursorMode(edgelessTool: GfxToolsFullOptionValue | null) {
if (!edgelessTool) {
return 'default';
}
switch (edgelessTool.type) {
case 'default':
return 'default';
case 'pan':
return edgelessTool.panning ? 'grabbing' : 'grab';
case 'brush':
case 'highlighter':
return drawingCursor;
case 'eraser':
case 'shape':
case 'connector':
case 'frame':
return 'crosshair';
case 'text':
return 'text';
default:
return 'default';
}
}
export type SelectableProps = {
bound: Bound;
rotate: number;
path?: PointLocation[];
};
export function getSelectableBounds(
selected: GfxModel[]
): Map<string, SelectableProps> {
const bounds = new Map();
getElementsWithoutGroup(selected).forEach(ele => {
const bound = Bound.deserialize(ele.xywh);
const props: SelectableProps = {
bound,
rotate: ele.rotate,
};
if (isCanvasElement(ele) && ele instanceof ConnectorElementModel) {
props.path = ele.absolutePath.map(p => p.clone());
}
bounds.set(ele.id, props);
});
return bounds;
}

View File

@@ -0,0 +1,762 @@
import { Overlay } from '@blocksuite/affine-block-surface';
import {
ConnectorElementModel,
MindmapElementModel,
} from '@blocksuite/affine-model';
import { almostEqual, Bound, Point } from '@blocksuite/global/gfx';
import type { GfxModel } from '@blocksuite/std/gfx';
interface Distance {
horiz?: {
/**
* the minimum x moving distance to align with other bound
*/
distance: number;
/**
* the indices of the align position
*/
alignPositionIndices: number[];
};
vert?: {
/**
* the minimum y moving distance to align with other bound
*/
distance: number;
/**
* the indices of the align position
*/
alignPositionIndices: number[];
};
}
const ALIGN_THRESHOLD = 8;
const DISTRIBUTION_LINE_OFFSET = 1;
const STROKE_WIDTH = 2;
export class SnapOverlay extends Overlay {
static override overlayName: string = 'snap-manager';
private _skippedElements: Set<GfxModel> = new Set();
private _referenceBounds: {
vertical: Bound[];
horizontal: Bound[];
all: Bound[];
} = {
vertical: [],
horizontal: [],
all: [],
};
/**
* This variable contains reference lines that are
* generated by the 'Distribute Alignment' function. This alignment is achieved
* by evenly distributing elements based on specified alignment rules.
* These lines serve as a guide for achieving equal spacing or distribution
* among multiple graphics or design elements.
*/
private _distributedAlignLines: [Point, Point][] = [];
/**
* This variable holds reference lines that are calculated
* based on the self-alignment of the graphics. This alignment is determined
* according to various aspects of the graphic itself, such as the center, edges,
* corners, etc. It essentially represents the guidelines for the positioning
* and alignment within the individual graphic elements.
*/
private _intraGraphicAlignLines: {
horizontal: [Point, Point][];
vertical: [Point, Point][];
} = {
horizontal: [],
vertical: [],
};
override clear() {
this._referenceBounds = {
vertical: [],
horizontal: [],
all: [],
};
this._intraGraphicAlignLines = {
horizontal: [],
vertical: [],
};
this._distributedAlignLines = [];
this._skippedElements.clear();
super.clear();
}
private _alignDistributeHorizontally(
rst: { dx: number; dy: number },
bound: Bound,
threshold: number,
viewport: { zoom: number }
) {
const wBoxes: Bound[] = [];
this._referenceBounds.horizontal.forEach(box => {
if (box.isHorizontalCross(bound)) {
wBoxes.push(box);
}
});
wBoxes.sort((a, b) => a.center[0] - b.center[0]);
let dif = Infinity;
let min = Infinity;
let aveDis = Number.MAX_SAFE_INTEGER;
let curBound!: {
leftIdx: number;
rightIdx: number;
spacing: number;
points: [Point, Point][];
};
for (let i = 0; i < wBoxes.length; i++) {
for (let j = i + 1; j < wBoxes.length; j++) {
let lb = wBoxes[i],
rb = wBoxes[j];
// it means these bound need to be horizontally across
if (!lb.isHorizontalCross(rb) || lb.isIntersectWithBound(rb)) continue;
let switchFlag = false;
// exchange lb and rb to make sure lb is on the left of rb
if (rb.maxX < lb.minX) {
const temp = rb;
rb = lb;
lb = temp;
switchFlag = true;
}
let _centerX = 0;
const updateDif = () => {
dif = Math.abs(bound.center[0] - _centerX);
const curAveDis =
(Math.abs(lb.center[0] - bound.center[0]) +
Math.abs(rb.center[0] - bound.center[0])) /
2;
if (
dif <= threshold &&
(dif < min || (almostEqual(dif, min) && curAveDis < aveDis))
) {
min = dif;
aveDis = curAveDis;
rst.dx = _centerX - bound.center[0];
/**
* calculate points to draw
*/
const ys = [lb.minY, lb.maxY, rb.minY, rb.maxY].sort(
(a, b) => a - b
);
const y = (ys[1] + ys[2]) / 2;
const offset = DISTRIBUTION_LINE_OFFSET / viewport.zoom;
const xs = [
_centerX - bound.w / 2,
_centerX + bound.w / 2,
rb.minX,
rb.maxX,
lb.minX,
lb.maxX,
].sort((a, b) => a - b);
curBound = {
leftIdx: switchFlag ? j : i,
rightIdx: switchFlag ? i : j,
spacing: xs[2] - xs[1],
points: [
[new Point(xs[1] + offset, y), new Point(xs[2] - offset, y)],
[new Point(xs[3] + offset, y), new Point(xs[4] - offset, y)],
],
};
}
};
/**
* align between left and right bound
*/
if (lb.horizontalDistance(rb) > bound.w) {
_centerX = (lb.maxX + rb.minX) / 2;
updateDif();
}
/**
* align to the left bounds
*/
_centerX = lb.minX - (rb.minX - lb.maxX) - bound.w / 2;
updateDif();
/** align right */
_centerX = rb.minX - lb.maxX + rb.maxX + bound.w / 2;
updateDif();
}
}
// find the boxes that has same spacing
if (curBound) {
const { leftIdx, rightIdx, spacing, points } = curBound;
this._distributedAlignLines.push(...points);
{
let curLeftBound = wBoxes[leftIdx];
for (let i = leftIdx - 1; i >= 0; i--) {
if (almostEqual(wBoxes[i].maxX, curLeftBound.minX - spacing)) {
const targetBound = wBoxes[i];
const ys = [
targetBound.minY,
targetBound.maxY,
curLeftBound.minY,
curLeftBound.maxY,
].sort((a, b) => a - b);
const y = (ys[1] + ys[2]) / 2;
this._distributedAlignLines.push([
new Point(wBoxes[i].maxX, y),
new Point(curLeftBound.minX, y),
]);
curLeftBound = wBoxes[i];
}
}
}
{
let curRightBound = wBoxes[rightIdx];
for (let i = rightIdx + 1; i < wBoxes.length; i++) {
if (almostEqual(wBoxes[i].minX, curRightBound.maxX + spacing)) {
const targetBound = wBoxes[i];
const ys = [
targetBound.minY,
targetBound.maxY,
curRightBound.minY,
curRightBound.maxY,
].sort((a, b) => a - b);
const y = (ys[1] + ys[2]) / 2;
this._distributedAlignLines.push([
new Point(curRightBound.maxX, y),
new Point(wBoxes[i].minX, y),
]);
curRightBound = wBoxes[i];
}
}
}
}
}
private _alignDistributeVertically(
rst: { dx: number; dy: number },
bound: Bound,
threshold: number,
viewport: { zoom: number }
) {
const hBoxes: Bound[] = [];
this._referenceBounds.vertical.forEach(box => {
if (box.isVerticalCross(bound)) {
hBoxes.push(box);
}
});
hBoxes.sort((a, b) => a.center[0] - b.center[0]);
let dif = Infinity;
let min = Infinity;
let aveDis = Number.MAX_SAFE_INTEGER;
let curBound!: {
upperIdx: number;
lowerIdx: number;
spacing: number;
points: [Point, Point][];
};
for (let i = 0; i < hBoxes.length; i++) {
for (let j = i + 1; j < hBoxes.length; j++) {
let ub = hBoxes[i],
db = hBoxes[j];
if (!ub.isVerticalCross(db) || ub.isIntersectWithBound(db)) continue;
let switchFlag = false;
if (db.maxY < ub.minX) {
const temp = ub;
ub = db;
db = temp;
switchFlag = true;
}
/** align middle */
let _centerY = 0;
const updateDiff = () => {
dif = Math.abs(bound.center[1] - _centerY);
const curAveDis =
(Math.abs(ub.center[1] - bound.center[1]) +
Math.abs(db.center[1] - bound.center[1])) /
2;
if (
dif <= threshold &&
(dif < min || (almostEqual(dif, min) && curAveDis < aveDis))
) {
min = dif;
rst.dy = _centerY - bound.center[1];
/**
* calculate points to draw
*/
const xs = [ub.minX, ub.maxX, db.minX, db.maxX].sort(
(a, b) => a - b
);
const x = (xs[1] + xs[2]) / 2;
const offset = DISTRIBUTION_LINE_OFFSET / viewport.zoom;
const ys = [
_centerY - bound.h / 2,
_centerY + bound.h / 2,
db.minY,
db.maxY,
ub.minY,
ub.maxY,
].sort((a, b) => a - b);
curBound = {
upperIdx: switchFlag ? j : i,
lowerIdx: switchFlag ? i : j,
spacing: ys[2] - ys[1],
points: [
[new Point(x, ys[1] + offset), new Point(x, ys[2] - offset)],
[new Point(x, ys[3] + offset), new Point(x, ys[4] - offset)],
],
};
}
};
if (ub.verticalDistance(db) > bound.h) {
_centerY = (ub.maxY + db.minY) / 2;
updateDiff();
}
/** align upper */
_centerY = ub.minY - (db.minY - ub.maxY) - bound.h / 2;
updateDiff();
/** align lower */
_centerY = db.minY - ub.maxY + db.maxY + bound.h / 2;
updateDiff();
}
}
// find the boxes that has same spacing
if (curBound) {
const { upperIdx, lowerIdx, spacing, points } = curBound;
this._distributedAlignLines.push(...points);
{
let curUpperBound = hBoxes[upperIdx];
for (let i = upperIdx - 1; i >= 0; i--) {
if (almostEqual(hBoxes[i].maxY, curUpperBound.minY - spacing)) {
const targetBound = hBoxes[i];
const xs = [
targetBound.minX,
targetBound.maxX,
curUpperBound.minX,
curUpperBound.maxX,
].sort((a, b) => a - b);
const x = (xs[1] + xs[2]) / 2;
this._distributedAlignLines.push([
new Point(x, hBoxes[i].maxY),
new Point(x, curUpperBound.minY),
]);
curUpperBound = hBoxes[i];
}
}
}
{
let curLowerBound = hBoxes[lowerIdx];
for (let i = lowerIdx + 1; i < hBoxes.length; i++) {
if (almostEqual(hBoxes[i].minY, curLowerBound.maxY + spacing)) {
const targetBound = hBoxes[i];
const xs = [
targetBound.minX,
targetBound.maxX,
curLowerBound.minX,
curLowerBound.maxX,
].sort((a, b) => a - b);
const x = (xs[1] + xs[2]) / 2;
this._distributedAlignLines.push([
new Point(x, curLowerBound.maxY),
new Point(x, hBoxes[i].minY),
]);
curLowerBound = hBoxes[i];
}
}
}
}
}
private _calculateClosestDistances(bound: Bound, other: Bound): Distance {
// Calculate center-to-center and center-to-side distances
const centerXDistance = other.center[0] - bound.center[0];
const centerYDistance = other.center[1] - bound.center[1];
// Calculate center-to-side distances
const leftDistance = other.minX - bound.center[0];
const rightDistance = other.maxX - bound.center[0];
const topDistance = other.minY - bound.center[1];
const bottomDistance = other.maxY - bound.center[1];
// Calculate side-to-side distances
const leftToLeft = other.minX - bound.minX;
const leftToRight = other.maxX - bound.minX;
const rightToLeft = other.minX - bound.maxX;
const rightToRight = other.maxX - bound.maxX;
const topToTop = other.minY - bound.minY;
const topToBottom = other.maxY - bound.minY;
const bottomToTop = other.minY - bound.maxY;
const bottomToBottom = other.maxY - bound.maxY;
// calculate side-to-center distances
const rightToCenter = other.center[0] - bound.maxX;
const leftToCenter = other.center[0] - bound.minX;
const topToCenter = other.center[1] - bound.minY;
const bottomToCenter = other.center[1] - bound.maxY;
const xDistances = [
centerXDistance,
leftDistance,
rightDistance,
leftToLeft,
leftToRight,
rightToLeft,
rightToRight,
rightToCenter,
leftToCenter,
];
const yDistances = [
centerYDistance,
topDistance,
bottomDistance,
topToTop,
topToBottom,
bottomToTop,
bottomToBottom,
topToCenter,
bottomToCenter,
];
// Get absolute distances
const xDistancesAbs = xDistances.map(Math.abs);
const yDistancesAbs = yDistances.map(Math.abs);
// Get closest distances
const closestX = Math.min(...xDistancesAbs);
const closestY = Math.min(...yDistancesAbs);
const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom;
// the x and y distances will be useful for locating the align point
return {
horiz:
closestX <= threshold
? {
distance: xDistances[xDistancesAbs.indexOf(closestX)],
get alignPositionIndices() {
const indices: number[] = [];
xDistancesAbs.forEach(
(val, idx) => almostEqual(val, closestX) && indices.push(idx)
);
return indices;
},
}
: undefined,
vert:
closestY <= threshold
? {
distance: yDistances[yDistancesAbs.indexOf(closestY)],
get alignPositionIndices() {
const indices: number[] = [];
yDistancesAbs.forEach(
(val, idx) => almostEqual(val, closestY) && indices.push(idx)
);
return indices;
},
}
: undefined,
};
}
/**
* Update horizontal moving distance `rst.dx` to align with other bound.
* Also, update the align points to draw.
* @param rst
* @param bound
* @param other
* @param distance
*/
private _updateXAlignPoint(
rst: { dx: number; dy: number },
bound: Bound,
other: Bound,
distance: Distance
) {
if (!distance.horiz) return;
const { distance: dx, alignPositionIndices: distanceIndices } =
distance.horiz;
const offset = STROKE_WIDTH / this.gfx.viewport.zoom / 2;
const alignXPosition = [
other.center[0],
other.minX + offset,
other.maxX - offset,
bound.minX + dx + offset,
bound.minX + dx + offset,
bound.maxX + dx - offset,
bound.maxX + dx - offset,
other.center[0] - offset,
other.center[0] + offset,
];
rst.dx = dx;
const dy = distance.vert?.distance ?? 0;
const top = Math.min(bound.minY + dy, other.minY);
const down = Math.max(bound.maxY + dy, other.maxY);
this._intraGraphicAlignLines.horizontal = distanceIndices.map(
idx =>
[
new Point(alignXPosition[idx], top),
new Point(alignXPosition[idx], down),
] as [Point, Point]
);
}
/**
* Update vertical moving distance `rst.dy` to align with other bound.
* Also, update the align points to draw.
* @param rst
* @param bound
* @param other
* @param distance
*/
private _updateYAlignPoint(
rst: { dx: number; dy: number },
bound: Bound,
other: Bound,
distance: Distance
) {
if (!distance.vert) return;
const { distance: dy, alignPositionIndices } = distance.vert;
const offset = STROKE_WIDTH / this.gfx.viewport.zoom / 2;
const alignXPosition = [
other.center[1] - offset,
other.minY + offset,
other.maxY - offset,
bound.minY + dy + offset,
bound.minY + dy + offset,
bound.maxY + dy - offset,
bound.maxY + dy - offset,
other.center[1] + offset,
other.center[1] - offset,
];
rst.dy = dy;
const dx = distance.horiz?.distance ?? 0;
const left = Math.min(bound.minX + dx, other.minX);
const right = Math.max(bound.maxX + dx, other.maxX);
this._intraGraphicAlignLines.vertical = alignPositionIndices.map(
idx =>
[
new Point(left, alignXPosition[idx]),
new Point(right, alignXPosition[idx]),
] as [Point, Point]
);
}
align(bound: Bound): { dx: number; dy: number } {
const rst = { dx: 0, dy: 0 };
const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom;
const { viewport } = this.gfx;
this._intraGraphicAlignLines = {
horizontal: [],
vertical: [],
};
this._distributedAlignLines = [];
this._updateAlignCandidates(bound);
for (const other of this._referenceBounds.all) {
const closestDistances = this._calculateClosestDistances(bound, other);
if (
closestDistances.horiz &&
(!this._intraGraphicAlignLines.horizontal.length ||
Math.abs(closestDistances.horiz.distance) < Math.abs(rst.dx))
) {
this._updateXAlignPoint(rst, bound, other, closestDistances);
}
if (
closestDistances.vert &&
(!this._intraGraphicAlignLines.vertical.length ||
Math.abs(closestDistances.vert.distance) < Math.abs(rst.dy))
) {
this._updateYAlignPoint(rst, bound, other, closestDistances);
}
}
// point align priority is higher than distribute align
if (rst.dx === 0) {
this._alignDistributeHorizontally(rst, bound, threshold, viewport);
}
if (rst.dy === 0) {
this._alignDistributeVertically(rst, bound, threshold, viewport);
}
this._renderer?.refresh();
return rst;
}
override render(ctx: CanvasRenderingContext2D) {
if (
this._intraGraphicAlignLines.vertical.length === 0 &&
this._intraGraphicAlignLines.horizontal.length === 0 &&
this._distributedAlignLines.length === 0
)
return;
const { viewport } = this.gfx;
const strokeWidth = STROKE_WIDTH / viewport.zoom;
ctx.strokeStyle = '#8B5CF6';
ctx.lineWidth = strokeWidth;
ctx.beginPath();
[
...this._intraGraphicAlignLines.horizontal,
...this._intraGraphicAlignLines.vertical,
].forEach(line => {
let d = '';
if (line[0].x === line[1].x) {
const x = line[0].x;
const minY = Math.min(line[0].y, line[1].y);
const maxY = Math.max(line[0].y, line[1].y);
d = `M${x},${minY}L${x},${maxY}`;
} else {
const y = line[0].y;
const minX = Math.min(line[0].x, line[1].x);
const maxX = Math.max(line[0].x, line[1].x);
d = `M${minX},${y}L${maxX},${y}`;
}
ctx.stroke(new Path2D(d));
});
ctx.strokeStyle = '#CC4187';
this._distributedAlignLines.forEach(line => {
const bar = 10 / viewport.zoom;
let d = '';
if (line[0].x === line[1].x) {
const x = line[0].x;
const minY = Math.min(line[0].y, line[1].y);
const maxY = Math.max(line[0].y, line[1].y);
d = `M${x},${minY}L${x},${maxY}
M${x - bar},${minY}L${x + bar},${minY}
M${x - bar},${maxY}L${x + bar},${maxY} `;
} else {
const y = line[0].y;
const minX = Math.min(line[0].x, line[1].x);
const maxX = Math.max(line[0].x, line[1].x);
d = `M${minX},${y}L${maxX},${y}
M${minX},${y - bar}L${minX},${y + bar}
M${maxX},${y - bar}L${maxX},${y + bar}`;
}
ctx.stroke(new Path2D(d));
});
}
private _isSkippedElement(element: GfxModel) {
return (
element instanceof ConnectorElementModel ||
element.group instanceof MindmapElementModel
);
}
private _updateAlignCandidates(movingBound: Bound) {
movingBound = movingBound.expand(ALIGN_THRESHOLD * this.gfx.viewport.zoom);
const viewportBound = this.gfx.viewport.viewportBounds;
const horizAreaBound = new Bound(
Math.min(movingBound.x, viewportBound.x),
movingBound.y,
Math.max(movingBound.w, viewportBound.w),
movingBound.h
);
const vertAreaBound = new Bound(
movingBound.x,
Math.min(movingBound.y, viewportBound.y),
movingBound.w,
Math.max(movingBound.h, viewportBound.h)
);
const { _skippedElements: skipped } = this;
const vertCandidates = this.gfx.grid.search(vertAreaBound, {
useSet: true,
});
const horizCandidates = this.gfx.grid.search(horizAreaBound, {
useSet: true,
});
const verticalBounds: Bound[] = [];
const horizBounds: Bound[] = [];
const allBounds: Bound[] = [];
vertCandidates.forEach(candidate => {
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
verticalBounds.push(candidate.elementBound);
allBounds.push(candidate.elementBound);
});
horizCandidates.forEach(candidate => {
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
horizBounds.push(candidate.elementBound);
allBounds.push(candidate.elementBound);
});
this._referenceBounds = {
horizontal: horizBounds,
vertical: verticalBounds,
all: allBounds,
};
}
setMovingElements(
movingElements: GfxModel[],
excludes: GfxModel[] = []
): Bound {
if (movingElements.length === 0) return new Bound();
const skipped = new Set(movingElements);
excludes.forEach(e => skipped.add(e));
this._skippedElements = skipped;
return movingElements.reduce(
(prev, element) => prev.unite(element.elementBound),
movingElements[0].elementBound
);
}
}

View File

@@ -0,0 +1,190 @@
import { effects as gfxBrushEffects } from '@blocksuite/affine-gfx-brush/effects';
import { effects as gfxConnectorEffects } from '@blocksuite/affine-gfx-connector/effects';
import { effects as gfxGroupEffects } from '@blocksuite/affine-gfx-group/effects';
import { effects as gfxMindmapEffects } from '@blocksuite/affine-gfx-mindmap/effects';
import { effects as gfxNoteEffects } from '@blocksuite/affine-gfx-note/effects';
import { effects as gfxShapeEffects } from '@blocksuite/affine-gfx-shape/effects';
import { effects as gfxTemplateEffects } from '@blocksuite/affine-gfx-template/effects';
import { effects as gfxCanvasTextEffects } from '@blocksuite/affine-gfx-text/effects';
import { effects as widgetEdgelessToolbarEffects } from '@blocksuite/affine-widget-edgeless-toolbar/effects';
import { EdgelessAutoCompletePanel } from './edgeless/components/auto-complete/auto-complete-panel.js';
import { EdgelessAutoComplete } from './edgeless/components/auto-complete/edgeless-auto-complete.js';
import {
NOTE_SLICER_WIDGET,
NoteSlicer,
} from './edgeless/components/note-slicer/index.js';
import {
EDGELESS_DRAGGING_AREA_WIDGET,
EdgelessDraggingAreaRectWidget,
} from './edgeless/components/rects/edgeless-dragging-area-rect.js';
import {
EDGELESS_SELECTED_RECT_WIDGET,
EdgelessSelectedRectWidget,
} from './edgeless/components/rects/edgeless-selected-rect.js';
import { EdgelessSlideMenu } from './edgeless/components/toolbar/common/slide-menu.js';
import { ToolbarArrowUpIcon } from './edgeless/components/toolbar/common/toolbar-arrow-up-icon.js';
import { EdgelessDefaultToolButton } from './edgeless/components/toolbar/default/default-tool-button.js';
import { EdgelessLinkToolButton } from './edgeless/components/toolbar/link/link-tool-button.js';
import {
AffineModalWidget,
EdgelessRootBlockComponent,
EdgelessRootPreviewBlockComponent,
PageRootBlockComponent,
PreviewRootBlockComponent,
} from './index.js';
import {
EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET,
EdgelessNavigatorBlackBackgroundWidget,
} from './widgets/edgeless-navigator-bg/index.js';
import {
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
AffineEdgelessZoomToolbarWidget,
} from './widgets/edgeless-zoom-toolbar/index.js';
import { ZoomBarToggleButton } from './widgets/edgeless-zoom-toolbar/zoom-bar-toggle-button.js';
import { EdgelessZoomToolbar } from './widgets/edgeless-zoom-toolbar/zoom-toolbar.js';
import {
AFFINE_INNER_MODAL_WIDGET,
AffineInnerModalWidget,
} from './widgets/inner-modal/inner-modal.js';
import { effects as widgetMobileToolbarEffects } from './widgets/keyboard-toolbar/effects.js';
import { effects as widgetLinkedDocEffects } from './widgets/linked-doc/effects.js';
import { Loader } from './widgets/linked-doc/import-doc/loader.js';
import { AffineCustomModal } from './widgets/modal/custom-modal.js';
import { AFFINE_MODAL_WIDGET } from './widgets/modal/modal.js';
import {
AFFINE_PAGE_DRAGGING_AREA_WIDGET,
AffinePageDraggingAreaWidget,
} from './widgets/page-dragging-area/page-dragging-area.js';
import {
AFFINE_VIEWPORT_OVERLAY_WIDGET,
AffineViewportOverlayWidget,
} from './widgets/viewport-overlay/viewport-overlay.js';
export function effects() {
// Run other effects
widgetMobileToolbarEffects();
widgetLinkedDocEffects();
widgetEdgelessToolbarEffects();
// Register components by category
registerRootComponents();
registerGfxEffects();
registerWidgets();
registerEdgelessToolbarComponents();
registerMiscComponents();
}
function registerRootComponents() {
customElements.define('affine-page-root', PageRootBlockComponent);
customElements.define('affine-preview-root', PreviewRootBlockComponent);
customElements.define('affine-edgeless-root', EdgelessRootBlockComponent);
customElements.define(
'affine-edgeless-root-preview',
EdgelessRootPreviewBlockComponent
);
}
function registerGfxEffects() {
gfxCanvasTextEffects();
gfxShapeEffects();
gfxNoteEffects();
gfxConnectorEffects();
gfxMindmapEffects();
gfxGroupEffects();
gfxBrushEffects();
gfxTemplateEffects();
}
function registerWidgets() {
customElements.define(AFFINE_INNER_MODAL_WIDGET, AffineInnerModalWidget);
customElements.define(AFFINE_MODAL_WIDGET, AffineModalWidget);
customElements.define(
AFFINE_PAGE_DRAGGING_AREA_WIDGET,
AffinePageDraggingAreaWidget
);
customElements.define(
AFFINE_VIEWPORT_OVERLAY_WIDGET,
AffineViewportOverlayWidget
);
customElements.define(
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
AffineEdgelessZoomToolbarWidget
);
}
function registerEdgelessToolbarComponents() {
// Tool buttons
customElements.define(
'edgeless-default-tool-button',
EdgelessDefaultToolButton
);
customElements.define('edgeless-link-tool-button', EdgelessLinkToolButton);
// Menus
customElements.define('edgeless-slide-menu', EdgelessSlideMenu);
// Toolbar components
customElements.define('toolbar-arrow-up-icon', ToolbarArrowUpIcon);
}
function registerMiscComponents() {
// Modal and menu components
customElements.define('affine-custom-modal', AffineCustomModal);
// Loading and preview components
customElements.define('loader-element', Loader);
// Toolbar and UI components
customElements.define('edgeless-zoom-toolbar', EdgelessZoomToolbar);
customElements.define('zoom-bar-toggle-button', ZoomBarToggleButton);
// Auto-complete components
customElements.define(
'edgeless-auto-complete-panel',
EdgelessAutoCompletePanel
);
customElements.define('edgeless-auto-complete', EdgelessAutoComplete);
// Note and template components
customElements.define(NOTE_SLICER_WIDGET, NoteSlicer);
// Navigation components
customElements.define(
EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET,
EdgelessNavigatorBlackBackgroundWidget
);
// Dragging area components
customElements.define(
EDGELESS_DRAGGING_AREA_WIDGET,
EdgelessDraggingAreaRectWidget
);
customElements.define(
EDGELESS_SELECTED_RECT_WIDGET,
EdgelessSelectedRectWidget
);
}
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-root': EdgelessRootBlockComponent;
'affine-edgeless-root-preview': EdgelessRootPreviewBlockComponent;
'edgeless-auto-complete-panel': EdgelessAutoCompletePanel;
'edgeless-auto-complete': EdgelessAutoComplete;
'note-slicer': NoteSlicer;
'edgeless-navigator-black-background': EdgelessNavigatorBlackBackgroundWidget;
'edgeless-dragging-area-rect': EdgelessDraggingAreaRectWidget;
'edgeless-selected-rect': EdgelessSelectedRectWidget;
'edgeless-slide-menu': EdgelessSlideMenu;
'toolbar-arrow-up-icon': ToolbarArrowUpIcon;
'edgeless-default-tool-button': EdgelessDefaultToolButton;
'edgeless-link-tool-button': EdgelessLinkToolButton;
'affine-page-root': PageRootBlockComponent;
'zoom-bar-toggle-button': ZoomBarToggleButton;
'edgeless-zoom-toolbar': EdgelessZoomToolbar;
[AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET]: AffineEdgelessZoomToolbarWidget;
[AFFINE_INNER_MODAL_WIDGET]: AffineInnerModalWidget;
}
}

View File

@@ -0,0 +1,16 @@
export * from './adapters';
export * from './clipboard/index.js';
export * from './common-specs/index.js';
export * from './edgeless/edgeless-builtin-spec.js';
export * from './edgeless/edgeless-root-spec.js';
export * from './edgeless/index.js';
export * from './page/page-root-block.js';
export { PageRootService } from './page/page-root-service.js';
export * from './page/page-root-spec.js';
export * from './preview/preview-root-block.js';
export * from './root-config.js';
export { RootService } from './root-service.js';
export * from './transformers/index.js';
export * from './types.js';
export * from './utils/index.js';
export * from './widgets/index.js';

View File

@@ -0,0 +1,163 @@
import {
convertSelectedBlocksToLinkedDoc,
getTitleFromSelectedModels,
notifyDocCreated,
promptDocTitle,
} from '@blocksuite/affine-block-embed';
import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph';
import { NoteBlockModel, ParagraphBlockModel } from '@blocksuite/affine-model';
import {
draftSelectedModelsCommand,
getSelectedModelsCommand,
} from '@blocksuite/affine-shared/commands';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { IS_MAC, IS_WINDOWS } from '@blocksuite/global/env';
import {
type BlockComponent,
BlockSelection,
type UIEventHandler,
} from '@blocksuite/std';
import { toDraftModel } from '@blocksuite/store';
export class PageKeyboardManager {
private readonly _handleDelete: UIEventHandler = ctx => {
const event = ctx.get('defaultState').event;
const blockSelections = this._currentSelection.filter(sel =>
sel.is(BlockSelection)
);
if (blockSelections.length === 0) {
return;
}
event.preventDefault();
const deletedBlocks: string[] = [];
blockSelections.forEach(sel => {
const id = sel.blockId;
const block = this._doc.getBlock(id);
if (!block) return;
const model = block.model;
if (
matchModels(model, [ParagraphBlockModel]) &&
model.props.type.startsWith('h') &&
model.props.collapsed
) {
const component = this.rootComponent.host.view.getBlock(id);
if (!(component instanceof ParagraphBlockComponent)) return;
const collapsedSiblings = component.collapsedSiblings;
deletedBlocks.push(
...[id, ...collapsedSiblings.map(sibling => sibling.id)].filter(
id => !deletedBlocks.includes(id)
)
);
} else {
deletedBlocks.push(id);
}
});
this._doc.transact(() => {
deletedBlocks.forEach(id => {
const block = this._doc.getBlock(id);
if (block) {
this._doc.deleteBlock(block.model);
}
});
this._selection.clear(['block', 'text']);
});
};
private get _currentSelection() {
return this._selection.value;
}
private get _doc() {
return this.rootComponent.doc;
}
private get _selection() {
return this.rootComponent.host.selection;
}
constructor(public rootComponent: BlockComponent) {
this.rootComponent.bindHotKey(
{
'Mod-z': ctx => {
ctx.get('defaultState').event.preventDefault();
if (this._doc.canUndo) {
this._doc.undo();
}
},
'Shift-Mod-z': ctx => {
ctx.get('defaultState').event.preventDefault();
if (this._doc.canRedo) {
this._doc.redo();
}
},
'Control-y': ctx => {
if (!IS_WINDOWS) return;
ctx.get('defaultState').event.preventDefault();
if (this._doc.canRedo) {
this._doc.redo();
}
},
'Mod-Backspace': () => true,
Backspace: this._handleDelete,
Delete: this._handleDelete,
'Control-d': ctx => {
if (!IS_MAC) return;
this._handleDelete(ctx);
},
'Mod-Shift-l': () => {
this._createEmbedBlock();
},
},
{
global: true,
}
);
}
private _createEmbedBlock() {
const rootComponent = this.rootComponent;
const [_, ctx] = this.rootComponent.std.command
.chain()
.pipe(getSelectedModelsCommand, {
types: ['block'],
mode: 'highest',
})
.pipe(draftSelectedModelsCommand)
.run();
const selectedModels = ctx.selectedModels?.filter(
block =>
!block.flavour.startsWith('affine:embed-') &&
matchModels(doc.getParent(block), [NoteBlockModel])
);
const draftedModels = ctx.draftedModels;
if (!selectedModels?.length || !draftedModels) {
return;
}
const doc = rootComponent.host.doc;
const autofill = getTitleFromSelectedModels(
selectedModels.map(toDraftModel)
);
promptDocTitle(rootComponent.std, autofill)
.then(title => {
if (title === null) return;
convertSelectedBlocksToLinkedDoc(
this.rootComponent.std,
doc,
draftedModels,
title
).catch(console.error);
notifyDocCreated(rootComponent.std, doc);
})
.catch(console.error);
}
}

View File

@@ -0,0 +1,427 @@
import { appendParagraphCommand } from '@blocksuite/affine-block-paragraph';
import {
CodeBlockModel,
ListBlockModel,
NoteBlockModel,
NoteDisplayMode,
ParagraphBlockModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
import { focusTextModel } from '@blocksuite/affine-rich-text';
import {
PageViewportService,
ViewportElementProvider,
} from '@blocksuite/affine-shared/services';
import {
focusTitle,
getClosestBlockComponentByPoint,
getDocTitleInlineEditor,
getScrollContainer,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { Point } from '@blocksuite/global/gfx';
import type { PointerEventState } from '@blocksuite/std';
import { BlockComponent, BlockSelection, TextSelection } from '@blocksuite/std';
import type { BlockModel, Text } from '@blocksuite/store';
import { css, html } from 'lit';
import { query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { PageRootBlockWidgetName } from '../index.js';
import { PageKeyboardManager } from '../keyboard/keyboard-manager.js';
import type { PageRootService } from './page-root-service.js';
const DOC_BLOCK_CHILD_PADDING = 24;
const DOC_BOTTOM_PADDING = 32;
function testClickOnBlankArea(
state: PointerEventState,
viewportLeft: number,
viewportWidth: number,
pageWidth: number,
paddingLeft: number,
paddingRight: number
) {
const blankLeft =
viewportLeft + (viewportWidth - pageWidth) / 2 + paddingLeft;
const blankRight =
viewportLeft + (viewportWidth - pageWidth) / 2 + pageWidth - paddingRight;
return state.raw.clientX < blankLeft || state.raw.clientX > blankRight;
}
export class PageRootBlockComponent extends BlockComponent<
RootBlockModel,
PageRootService,
PageRootBlockWidgetName
> {
static override styles = css`
editor-host:has(> affine-page-root, * > affine-page-root) {
display: block;
height: 100%;
}
affine-page-root {
display: block;
height: 100%;
cursor: default;
}
.affine-page-root-block-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
font-family: var(--affine-font-family);
font-size: var(--affine-font-base);
line-height: var(--affine-line-height);
color: var(--affine-text-primary-color);
font-weight: 400;
max-width: var(--affine-editor-width);
margin: 0 auto;
/* Leave a place for drag-handle */
/* Do not use prettier format this style, or it will be broken */
/* prettier-ignore */
padding-left: var(--affine-editor-side-padding, ${DOC_BLOCK_CHILD_PADDING}px);
/* prettier-ignore */
padding-right: var(--affine-editor-side-padding, ${DOC_BLOCK_CHILD_PADDING}px);
/* prettier-ignore */
padding-bottom: var(--affine-editor-bottom-padding, ${DOC_BOTTOM_PADDING}px);
}
/* Extra small devices (phones, 640px and down) */
@container viewport (width <= 640px) {
.affine-page-root-block-container {
padding-left: ${DOC_BLOCK_CHILD_PADDING}px;
padding-right: ${DOC_BLOCK_CHILD_PADDING}px;
}
}
.affine-block-element {
display: block;
}
@media print {
.selected {
background-color: transparent !important;
}
}
`;
/**
* Focus the first paragraph in the default note block.
* If there is no paragraph, create one.
* @return { id: string, created: boolean } id of the focused paragraph and whether it is created or not
*/
focusFirstParagraph = (): { id: string; created: boolean } => {
const defaultNote = this._getDefaultNoteBlock();
const firstText = defaultNote?.children.find(block =>
matchModels(block, [ParagraphBlockModel, ListBlockModel, CodeBlockModel])
);
if (firstText) {
focusTextModel(this.std, firstText.id);
return { id: firstText.id, created: false };
} else {
const newFirstParagraphId = this.doc.addBlock(
'affine:paragraph',
{},
defaultNote,
0
);
focusTextModel(this.std, newFirstParagraphId);
return { id: newFirstParagraphId, created: true };
}
};
keyboardManager: PageKeyboardManager | null = null;
prependParagraphWithText = (text: Text) => {
const newFirstParagraphId = this.doc.addBlock(
'affine:paragraph',
{ text },
this._getDefaultNoteBlock(),
0
);
focusTextModel(this.std, newFirstParagraphId);
};
get rootScrollContainer() {
return getScrollContainer(this);
}
get viewportProvider() {
return this.std.get(ViewportElementProvider);
}
get viewport() {
return this.viewportProvider.viewport;
}
get viewportElement(): HTMLElement {
return this.viewportProvider.viewportElement;
}
private _createDefaultNoteBlock() {
const { doc } = this;
const noteId = doc.addBlock('affine:note', {}, doc.root?.id);
return doc.getModelById(noteId) as NoteBlockModel;
}
private _getDefaultNoteBlock() {
return (
this.doc.root?.children.find(child => child.flavour === 'affine:note') ??
this._createDefaultNoteBlock()
);
}
private _initViewportResizeEffect() {
const viewport = this.viewport;
const viewportElement = this.viewportElement;
if (!viewport || !viewportElement) {
return;
}
const viewportService = this.std.get(PageViewportService);
// when observe viewportElement resize, emit viewport update event
const resizeObserver = new ResizeObserver(
(entries: ResizeObserverEntry[]) => {
for (const { target } of entries) {
if (target === viewportElement) {
viewportService.next(viewport);
break;
}
}
}
);
resizeObserver.observe(viewportElement);
this.disposables.add(() => {
resizeObserver.unobserve(viewportElement);
resizeObserver.disconnect();
});
}
override connectedCallback() {
super.connectedCallback();
this.keyboardManager = new PageKeyboardManager(this);
this.bindHotKey({
'Mod-a': () => {
const blocks = this.model.children
.filter(model => {
if (matchModels(model, [NoteBlockModel])) {
if (model.props.displayMode === NoteDisplayMode.EdgelessOnly)
return false;
return true;
}
return false;
})
.flatMap(model => {
return model.children.map(child => {
return this.std.selection.create(BlockSelection, {
blockId: child.id,
});
});
});
this.std.selection.setGroup('note', blocks);
return true;
},
ArrowUp: () => {
const selection = this.host.selection;
const sel = selection.value.find(
sel => sel.is(TextSelection) || sel.is(BlockSelection)
);
if (!sel) return;
let model: BlockModel | null = null;
let current = this.doc.getModelById(sel.blockId);
while (current && !model) {
if (current.flavour === 'affine:note') {
model = current;
} else {
current = this.doc.getParent(current);
}
}
if (!model) return;
const prevNote = this.doc.getPrev(model);
if (!prevNote || prevNote.flavour !== 'affine:note') {
const isFirstText = sel.is(TextSelection) && sel.start.index === 0;
const isBlock = sel.is(BlockSelection);
if (isBlock || isFirstText) {
focusTitle(this.host);
}
return;
}
const notes = this.doc.getModelsByFlavour('affine:note');
const index = notes.indexOf(prevNote);
if (index !== 0) return;
const range = this.std.range.value;
requestAnimationFrame(() => {
const currentRange = this.std.range.value;
if (!range || !currentRange) return;
// If the range has not changed, it means we need to manually move the cursor to the title.
if (
range.startContainer === currentRange.startContainer &&
range.startOffset === currentRange.startOffset &&
range.endContainer === currentRange.endContainer &&
range.endOffset === currentRange.endOffset
) {
const titleInlineEditor = getDocTitleInlineEditor(this.host);
if (titleInlineEditor) {
titleInlineEditor.focusEnd();
}
}
});
},
});
this.handleEvent('pointerDown', ctx => {
const event = ctx.get('pointerState');
if (
event.raw.target !== this &&
event.raw.target !== this.viewportElement &&
event.raw.target !== this.rootElementContainer
) {
return;
}
// prevent cursor jump
event.raw.preventDefault();
});
this.handleEvent('click', ctx => {
const event = ctx.get('pointerState');
if (
event.raw.target !== this &&
event.raw.target !== this.viewportElement &&
event.raw.target !== this.rootElementContainer
) {
return;
}
const notes = this.model.children.filter(
(child): child is NoteBlockModel =>
child instanceof NoteBlockModel &&
child.props.displayMode !== NoteDisplayMode.EdgelessOnly
);
// make sure there is a block can be focused
if (notes.length === 0 || notes[notes.length - 1].children.length === 0) {
this.std.command.exec(appendParagraphCommand);
return;
}
const { paddingLeft, paddingRight } = window.getComputedStyle(
this.rootElementContainer
);
if (!this.viewport) return;
const isClickOnBlankArea = testClickOnBlankArea(
event,
this.viewport.left,
this.viewport.clientWidth,
this.rootElementContainer.clientWidth,
parseFloat(paddingLeft),
parseFloat(paddingRight)
);
if (!isClickOnBlankArea) {
const lastBlock = notes[notes.length - 1].lastChild();
if (
!lastBlock ||
!matchModels(lastBlock, [ParagraphBlockModel]) ||
lastBlock.props.text.length !== 0
) {
this.std.command.exec(appendParagraphCommand);
}
return;
}
const hostRect = this.host.getBoundingClientRect();
const x = hostRect.width / 2 + hostRect.left;
const point = new Point(x, event.raw.clientY);
const side = event.raw.clientX < x ? 'left' : 'right';
const nearestBlock = getClosestBlockComponentByPoint(point);
event.raw.preventDefault();
if (nearestBlock) {
const text = nearestBlock.model.text;
if (text) {
this.host.selection.setGroup('note', [
this.host.selection.create(TextSelection, {
from: {
blockId: nearestBlock.model.id,
index: side === 'left' ? 0 : text.length,
length: 0,
},
to: null,
}),
]);
} else {
this.host.selection.setGroup('note', [
this.host.selection.create(BlockSelection, {
blockId: nearestBlock.model.id,
}),
]);
}
} else {
if (this.host.selection.find(BlockSelection)) {
this.host.selection.clear(['block']);
}
}
return;
});
}
override disconnectedCallback() {
super.disconnectedCallback();
this._disposables.dispose();
this.keyboardManager = null;
}
override firstUpdated() {
this._initViewportResizeEffect();
const noteModels = this.model.children.filter(model =>
matchModels(model, [NoteBlockModel])
);
noteModels.forEach(note => {
this.disposables.add(
note.propsUpdated.subscribe(({ key }) => {
if (key === 'displayMode') {
this.requestUpdate();
}
})
);
});
}
override renderBlock() {
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
const children = this.renderChildren(this.model, child => {
const isNote = matchModels(child, [NoteBlockModel]);
const note = child as NoteBlockModel;
const displayOnEdgeless =
!!note.props.displayMode &&
note.props.displayMode === NoteDisplayMode.EdgelessOnly;
// Should remove deprecated `hidden` property in the future
return !(isNote && displayOnEdgeless);
});
this.contentEditable = String(!this.doc.readonly$.value);
return html`
<div class="affine-page-root-block-container">${children} ${widgets}</div>
`;
}
@query('.affine-page-root-block-container')
accessor rootElementContainer!: HTMLDivElement;
}

View File

@@ -0,0 +1,7 @@
import { RootBlockSchema } from '@blocksuite/affine-model';
import { RootService } from '../root-service.js';
export class PageRootService extends RootService {
static override readonly flavour = RootBlockSchema.model.flavour;
}

View File

@@ -0,0 +1,41 @@
import { ViewportElementExtension } from '@blocksuite/affine-shared/services';
import { BlockViewExtension, WidgetViewExtension } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { PageClipboard } from '../clipboard/page-clipboard.js';
import { CommonSpecs } from '../common-specs/index.js';
import { AFFINE_KEYBOARD_TOOLBAR_WIDGET } from '../widgets/keyboard-toolbar/index.js';
import { AFFINE_PAGE_DRAGGING_AREA_WIDGET } from '../widgets/page-dragging-area/page-dragging-area.js';
import { PageRootService } from './page-root-service.js';
export const keyboardToolbarWidget = WidgetViewExtension(
'affine:page',
AFFINE_KEYBOARD_TOOLBAR_WIDGET,
literal`${unsafeStatic(AFFINE_KEYBOARD_TOOLBAR_WIDGET)}`
);
export const pageDraggingAreaWidget = WidgetViewExtension(
'affine:page',
AFFINE_PAGE_DRAGGING_AREA_WIDGET,
literal`${unsafeStatic(AFFINE_PAGE_DRAGGING_AREA_WIDGET)}`
);
const PageCommonExtension: ExtensionType[] = [
CommonSpecs,
PageRootService,
pageDraggingAreaWidget,
ViewportElementExtension('.affine-page-viewport'),
].flat();
export const PageRootBlockSpec: ExtensionType[] = [
...PageCommonExtension,
BlockViewExtension('affine:page', literal`affine-page-root`),
keyboardToolbarWidget,
PageClipboard,
].flat();
export const PreviewPageRootBlockSpec: ExtensionType[] = [
...PageCommonExtension,
BlockViewExtension('affine:page', literal`affine-preview-root`),
];

View File

@@ -0,0 +1,41 @@
import { NoteBlockModel, NoteDisplayMode } from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { BlockComponent } from '@blocksuite/std';
import { css, html } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
export class PreviewRootBlockComponent extends BlockComponent {
static override styles = css`
affine-preview-root {
display: block;
}
`;
override connectedCallback() {
super.connectedCallback();
}
override disconnectedCallback() {
super.disconnectedCallback();
}
override renderBlock() {
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
const children = this.renderChildren(this.model, child => {
const isNote = matchModels(child, [NoteBlockModel]);
const note = child as NoteBlockModel;
const displayOnEdgeless =
!!note.props.displayMode &&
note.props.displayMode === NoteDisplayMode.EdgelessOnly;
// Should remove deprecated `hidden` property in the future
return !(isNote && displayOnEdgeless);
});
return html`<div class="affine-preview-root">${children} ${widgets}</div>`;
}
}

View File

@@ -0,0 +1,12 @@
import { ConfigExtensionFactory } from '@blocksuite/std';
import type { KeyboardToolbarConfig } from './widgets/keyboard-toolbar/config.js';
import type { LinkedWidgetConfig } from './widgets/linked-doc/index.js';
export interface RootBlockConfig {
linkedWidget?: Partial<LinkedWidgetConfig>;
keyboardToolbar?: Partial<KeyboardToolbarConfig>;
}
export const RootBlockConfigExtension =
ConfigExtensionFactory<RootBlockConfig>('affine:root-block');

View File

@@ -0,0 +1,48 @@
import { RootBlockSchema } from '@blocksuite/affine-model';
import {
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSelectedBlocksCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type { BlockComponent } from '@blocksuite/std';
import { BlockService } from '@blocksuite/std';
import type { RootBlockComponent } from './types.js';
export abstract class RootService extends BlockService {
static override readonly flavour = RootBlockSchema.model.flavour;
get selectedBlocks() {
let result: BlockComponent[] = [];
this.std.command
.chain()
.tryAll(chain => [
chain.pipe(getTextSelectionCommand),
chain.pipe(getImageSelectionsCommand),
chain.pipe(getBlockSelectionsCommand),
])
.pipe(getSelectedBlocksCommand)
.pipe(({ selectedBlocks }) => {
if (!selectedBlocks) return;
result = selectedBlocks;
})
.run();
return result;
}
get selectedModels() {
return this.selectedBlocks.map(block => block.model);
}
get viewportElement() {
const rootId = this.std.store.root?.id;
if (!rootId) return null;
const rootComponent = this.std.view.getBlock(
rootId
) as RootBlockComponent | null;
if (!rootComponent) return null;
const viewportElement = rootComponent.viewportElement;
return viewportElement;
}
}

View File

@@ -0,0 +1,203 @@
import { defaultImageProxyMiddleware } from '@blocksuite/affine-block-image';
import {
docLinkBaseURLMiddleware,
fileNameMiddleware,
HtmlAdapter,
titleMiddleware,
} from '@blocksuite/affine-shared/adapters';
import { SpecProvider } from '@blocksuite/affine-shared/utils';
import { Container } from '@blocksuite/global/di';
import { sha } from '@blocksuite/global/utils';
import type { Schema, Store, Workspace } from '@blocksuite/store';
import { extMimeMap, Transformer } from '@blocksuite/store';
import { createAssetsArchive, download, Unzip } from './utils.js';
type ImportHTMLToDocOptions = {
collection: Workspace;
schema: Schema;
html: string;
fileName?: string;
};
type ImportHTMLZipOptions = {
collection: Workspace;
schema: Schema;
imported: Blob;
};
function getProvider() {
const container = new Container();
const exts = SpecProvider._.getSpec('store').value;
exts.forEach(ext => {
ext.setup(container);
});
return container.provider();
}
/**
* Exports a doc to HTML format.
*
* @param doc - The doc to be exported.
* @returns A Promise that resolves when the export is complete.
*/
async function exportDoc(doc: Store) {
const provider = getProvider();
const job = doc.getTransformer([
docLinkBaseURLMiddleware(doc.workspace.id),
titleMiddleware(doc.workspace.meta.docMetas),
]);
const snapshot = job.docToSnapshot(doc);
const adapter = new HtmlAdapter(job, provider);
if (!snapshot) {
return;
}
const htmlResult = await adapter.fromDocSnapshot({
snapshot,
assets: job.assetsManager,
});
let downloadBlob: Blob;
const docTitle = doc.meta?.title || 'Untitled';
let name: string;
const contentBlob = new Blob([htmlResult.file], { type: 'plain/text' });
if (htmlResult.assetsIds.length > 0) {
const zip = await createAssetsArchive(job.assets, htmlResult.assetsIds);
await zip.file('index.html', contentBlob);
downloadBlob = await zip.generate();
name = `${docTitle}.zip`;
} else {
downloadBlob = contentBlob;
name = `${docTitle}.html`;
}
download(downloadBlob, name);
}
/**
* Imports HTML content into a new doc within a collection.
*
* @param options - The import options.
* @param options.collection - The target doc collection.
* @param options.schema - The schema of the target doc collection.
* @param options.html - The HTML content to import.
* @param options.fileName - Optional filename for the imported doc.
* @returns A Promise that resolves to the ID of the newly created doc, or undefined if import fails.
*/
async function importHTMLToDoc({
collection,
schema,
html,
fileName,
}: ImportHTMLToDocOptions) {
const provider = getProvider();
const job = new Transformer({
schema,
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => collection.createDoc(id).getStore({ id }),
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
delete: (id: string) => collection.removeDoc(id),
},
middlewares: [
defaultImageProxyMiddleware,
fileNameMiddleware(fileName),
docLinkBaseURLMiddleware(collection.id),
],
});
const htmlAdapter = new HtmlAdapter(job, provider);
const page = await htmlAdapter.toDoc({
file: html,
assets: job.assetsManager,
});
if (!page) {
return;
}
return page.id;
}
/**
* Imports a zip file containing HTML files and assets into a collection.
*
* @param options - The import options.
* @param options.collection - The target doc collection.
* @param options.schema - The schema of the target doc collection.
* @param options.imported - The zip file as a Blob.
* @returns A Promise that resolves to an array of IDs of the newly created docs.
*/
async function importHTMLZip({
collection,
schema,
imported,
}: ImportHTMLZipOptions) {
const provider = getProvider();
const unzip = new Unzip();
await unzip.load(imported);
const docIds: string[] = [];
const pendingAssets = new Map<string, File>();
const pendingPathBlobIdMap = new Map<string, string>();
const htmlBlobs: [string, Blob][] = [];
for (const { path, content: blob } of unzip) {
if (path.includes('__MACOSX') || path.includes('.DS_Store')) {
continue;
}
const fileName = path.split('/').pop() ?? '';
if (fileName.endsWith('.html')) {
htmlBlobs.push([fileName, blob]);
} else {
const ext = path.split('.').at(-1) ?? '';
const mime = extMimeMap.get(ext) ?? '';
const key = await sha(await blob.arrayBuffer());
pendingPathBlobIdMap.set(path, key);
pendingAssets.set(key, new File([blob], fileName, { type: mime }));
}
}
await Promise.all(
htmlBlobs.map(async ([fileName, blob]) => {
const fileNameWithoutExt = fileName.replace(/\.[^/.]+$/, '');
const job = new Transformer({
schema,
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => collection.createDoc(id).getStore({ id }),
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
delete: (id: string) => collection.removeDoc(id),
},
middlewares: [
defaultImageProxyMiddleware,
fileNameMiddleware(fileNameWithoutExt),
docLinkBaseURLMiddleware(collection.id),
],
});
const assets = job.assets;
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
for (const [key, value] of pendingAssets.entries()) {
assets.set(key, value);
}
for (const [key, value] of pendingPathBlobIdMap.entries()) {
pathBlobIdMap.set(key, value);
}
const htmlAdapter = new HtmlAdapter(job, provider);
const html = await blob.text();
const doc = await htmlAdapter.toDoc({
file: html,
assets: job.assetsManager,
});
if (doc) {
docIds.push(doc.id);
}
})
);
return docIds;
}
export const HtmlTransformer = {
exportDoc,
importHTMLToDoc,
importHTMLZip,
};

View File

@@ -0,0 +1,5 @@
export { HtmlTransformer } from './html.js';
export { MarkdownTransformer } from './markdown.js';
export { NotionHtmlTransformer } from './notion-html.js';
export { createAssetsArchive, download } from './utils.js';
export { ZipTransformer } from './zip.js';

View File

@@ -0,0 +1,255 @@
import { defaultImageProxyMiddleware } from '@blocksuite/affine-block-image';
import {
docLinkBaseURLMiddleware,
fileNameMiddleware,
MarkdownAdapter,
titleMiddleware,
} from '@blocksuite/affine-shared/adapters';
import { SpecProvider } from '@blocksuite/affine-shared/utils';
import { Container } from '@blocksuite/global/di';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { sha } from '@blocksuite/global/utils';
import type { Schema, Store, Workspace } from '@blocksuite/store';
import { extMimeMap, Transformer } from '@blocksuite/store';
import { createAssetsArchive, download, Unzip } from './utils.js';
function getProvider() {
const container = new Container();
const exts = SpecProvider._.getSpec('store').value;
exts.forEach(ext => {
ext.setup(container);
});
return container.provider();
}
type ImportMarkdownToBlockOptions = {
doc: Store;
markdown: string;
blockId: string;
};
type ImportMarkdownToDocOptions = {
collection: Workspace;
schema: Schema;
markdown: string;
fileName?: string;
};
type ImportMarkdownZipOptions = {
collection: Workspace;
schema: Schema;
imported: Blob;
};
/**
* Exports a doc to a Markdown file or a zip archive containing Markdown and assets.
* @param doc The doc to export
* @returns A Promise that resolves when the export is complete
*/
async function exportDoc(doc: Store) {
const provider = getProvider();
const job = doc.getTransformer([
docLinkBaseURLMiddleware(doc.workspace.id),
titleMiddleware(doc.workspace.meta.docMetas),
]);
const snapshot = job.docToSnapshot(doc);
const adapter = new MarkdownAdapter(job, provider);
if (!snapshot) {
return;
}
const markdownResult = await adapter.fromDocSnapshot({
snapshot,
assets: job.assetsManager,
});
let downloadBlob: Blob;
const docTitle = doc.meta?.title || 'Untitled';
let name: string;
const contentBlob = new Blob([markdownResult.file], { type: 'plain/text' });
if (markdownResult.assetsIds.length > 0) {
if (!job.assets) {
throw new BlockSuiteError(ErrorCode.ValueNotExists, 'No assets found');
}
const zip = await createAssetsArchive(job.assets, markdownResult.assetsIds);
await zip.file('index.md', contentBlob);
downloadBlob = await zip.generate();
name = `${docTitle}.zip`;
} else {
downloadBlob = contentBlob;
name = `${docTitle}.md`;
}
download(downloadBlob, name);
}
/**
* Imports Markdown content into a specific block within a doc.
* @param options Object containing import options
* @param options.doc The target doc
* @param options.markdown The Markdown content to import
* @param options.blockId The ID of the block where the content will be imported
* @returns A Promise that resolves when the import is complete
*/
async function importMarkdownToBlock({
doc,
markdown,
blockId,
}: ImportMarkdownToBlockOptions) {
const provider = getProvider();
const job = doc.getTransformer([
defaultImageProxyMiddleware,
docLinkBaseURLMiddleware(doc.workspace.id),
]);
const adapter = new MarkdownAdapter(job, provider);
const snapshot = await adapter.toSliceSnapshot({
file: markdown,
assets: job.assetsManager,
workspaceId: doc.workspace.id,
pageId: doc.id,
});
if (!snapshot) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.ValueNotExists,
'import markdown failed, expected to get a snapshot'
);
}
const blocks = snapshot.content.flatMap(x => x.children);
for (const block of blocks) {
await job.snapshotToBlock(block, doc, blockId);
}
return;
}
/**
* Imports Markdown content into a new doc within a collection.
* @param options Object containing import options
* @param options.collection The target doc collection
* @param options.schema The schema of the target doc collection
* @param options.markdown The Markdown content to import
* @param options.fileName Optional filename for the imported doc
* @returns A Promise that resolves to the ID of the newly created doc, or undefined if import fails
*/
async function importMarkdownToDoc({
collection,
schema,
markdown,
fileName,
}: ImportMarkdownToDocOptions) {
const provider = getProvider();
const job = new Transformer({
schema,
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => collection.createDoc(id).getStore({ id }),
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
delete: (id: string) => collection.removeDoc(id),
},
middlewares: [
defaultImageProxyMiddleware,
fileNameMiddleware(fileName),
docLinkBaseURLMiddleware(collection.id),
],
});
const mdAdapter = new MarkdownAdapter(job, provider);
const page = await mdAdapter.toDoc({
file: markdown,
assets: job.assetsManager,
});
if (!page) {
return;
}
return page.id;
}
/**
* Imports a zip file containing Markdown files and assets into a collection.
* @param options Object containing import options
* @param options.collection The target doc collection
* @param options.schema The schema of the target doc collection
* @param options.imported The zip file as a Blob
* @returns A Promise that resolves to an array of IDs of the newly created docs
*/
async function importMarkdownZip({
collection,
schema,
imported,
}: ImportMarkdownZipOptions) {
const provider = getProvider();
const unzip = new Unzip();
await unzip.load(imported);
const docIds: string[] = [];
const pendingAssets = new Map<string, File>();
const pendingPathBlobIdMap = new Map<string, string>();
const markdownBlobs: [string, Blob][] = [];
for (const { path, content: blob } of unzip) {
if (path.includes('__MACOSX') || path.includes('.DS_Store')) {
continue;
}
const fileName = path.split('/').pop() ?? '';
if (fileName.endsWith('.md')) {
markdownBlobs.push([fileName, blob]);
} else {
const ext = path.split('.').at(-1) ?? '';
const mime = extMimeMap.get(ext) ?? '';
const key = await sha(await blob.arrayBuffer());
pendingPathBlobIdMap.set(path, key);
pendingAssets.set(key, new File([blob], fileName, { type: mime }));
}
}
await Promise.all(
markdownBlobs.map(async ([fileName, blob]) => {
const fileNameWithoutExt = fileName.replace(/\.[^/.]+$/, '');
const job = new Transformer({
schema,
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => collection.createDoc(id).getStore({ id }),
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
delete: (id: string) => collection.removeDoc(id),
},
middlewares: [
defaultImageProxyMiddleware,
fileNameMiddleware(fileNameWithoutExt),
docLinkBaseURLMiddleware(collection.id),
],
});
const assets = job.assets;
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
for (const [key, value] of pendingAssets.entries()) {
assets.set(key, value);
}
for (const [key, value] of pendingPathBlobIdMap.entries()) {
pathBlobIdMap.set(key, value);
}
const mdAdapter = new MarkdownAdapter(job, provider);
const markdown = await blob.text();
const doc = await mdAdapter.toDoc({
file: markdown,
assets: job.assetsManager,
});
if (doc) {
docIds.push(doc.id);
}
})
);
return docIds;
}
export const MarkdownTransformer = {
exportDoc,
importMarkdownToBlock,
importMarkdownToDoc,
importMarkdownZip,
};

View File

@@ -0,0 +1,171 @@
import { defaultImageProxyMiddleware } from '@blocksuite/affine-block-image';
import { NotionHtmlAdapter } from '@blocksuite/affine-shared/adapters';
import { SpecProvider } from '@blocksuite/affine-shared/utils';
import { Container } from '@blocksuite/global/di';
import { sha } from '@blocksuite/global/utils';
import {
extMimeMap,
type Schema,
Transformer,
type Workspace,
} from '@blocksuite/store';
import { Unzip } from './utils.js';
type ImportNotionZipOptions = {
collection: Workspace;
schema: Schema;
imported: Blob;
};
function getProvider() {
const container = new Container();
const exts = SpecProvider._.getSpec('store').value;
exts.forEach(ext => {
ext.setup(container);
});
return container.provider();
}
/**
* Imports a Notion zip file into the BlockSuite collection.
*
* @param options - The options for importing.
* @param options.collection - The BlockSuite document collection.
* @param options.schema - The schema of the BlockSuite document collection.
* @param options.imported - The imported zip file as a Blob.
*
* @returns A promise that resolves to an object containing:
* - entryId: The ID of the entry page (if any).
* - pageIds: An array of imported page IDs.
* - isWorkspaceFile: Whether the imported file is a workspace file.
* - hasMarkdown: Whether the zip contains markdown files.
*/
async function importNotionZip({
collection,
schema,
imported,
}: ImportNotionZipOptions) {
const provider = getProvider();
const pageIds: string[] = [];
let isWorkspaceFile = false;
let hasMarkdown = false;
let entryId: string | undefined;
const parseZipFile = async (path: File | Blob) => {
const unzip = new Unzip();
await unzip.load(path);
const zipFile = new Map<string, Blob>();
const pageMap = new Map<string, string>();
const pagePaths: string[] = [];
const promises: Promise<void>[] = [];
const pendingAssets = new Map<string, Blob>();
const pendingPathBlobIdMap = new Map<string, string>();
for (const { path, content, index } of unzip) {
if (path.startsWith('__MACOSX/')) continue;
zipFile.set(path, content);
const lastSplitIndex = path.lastIndexOf('/');
const fileName = path.substring(lastSplitIndex + 1);
if (fileName.endsWith('.md')) {
hasMarkdown = true;
continue;
}
if (fileName.endsWith('.html')) {
if (path.endsWith('/index.html')) {
isWorkspaceFile = true;
continue;
}
if (lastSplitIndex !== -1) {
const text = await content.text();
const doc = new DOMParser().parseFromString(text, 'text/html');
const pageBody = doc.querySelector('.page-body');
if (pageBody && pageBody.children.length === 0) {
// Skip empty pages
continue;
}
}
const id = collection.idGenerator();
const splitPath = path.split('/');
while (splitPath.length > 0) {
pageMap.set(splitPath.join('/'), id);
splitPath.shift();
}
pagePaths.push(path);
if (entryId === undefined && lastSplitIndex === -1) {
entryId = id;
}
continue;
}
if (index === 0 && fileName.endsWith('.csv')) {
window.open(
'https://affine.pro/blog/import-your-data-from-notion-into-affine',
'_blank'
);
continue;
}
if (fileName.endsWith('.zip')) {
const innerZipFile = content;
if (innerZipFile) {
promises.push(...(await parseZipFile(innerZipFile)));
}
continue;
}
const blob = content;
const ext = path.split('.').at(-1) ?? '';
const mime = extMimeMap.get(ext) ?? '';
const key = await sha(await blob.arrayBuffer());
const filePathSplit = path.split('/');
while (filePathSplit.length > 1) {
pendingPathBlobIdMap.set(filePathSplit.join('/'), key);
filePathSplit.shift();
}
pendingAssets.set(key, new File([blob], fileName, { type: mime }));
}
const pagePromises = Array.from(pagePaths).map(async path => {
const job = new Transformer({
schema,
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => collection.createDoc(id).getStore({ id }),
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
delete: (id: string) => collection.removeDoc(id),
},
middlewares: [defaultImageProxyMiddleware],
});
const htmlAdapter = new NotionHtmlAdapter(job, provider);
const assets = job.assetsManager.getAssets();
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
for (const [key, value] of pendingAssets.entries()) {
if (!assets.has(key)) {
assets.set(key, value);
}
}
for (const [key, value] of pendingPathBlobIdMap.entries()) {
if (!pathBlobIdMap.has(key)) {
pathBlobIdMap.set(key, value);
}
}
const page = await htmlAdapter.toDoc({
file: await zipFile.get(path)!.text(),
pageId: pageMap.get(path),
pageMap,
assets: job.assetsManager,
});
if (page) {
pageIds.push(page.id);
}
});
promises.push(...pagePromises);
return promises;
};
const allPromises = await parseZipFile(imported);
await Promise.all(allPromises.flat());
entryId = entryId ?? pageIds[0];
return { entryId, pageIds, isWorkspaceFile, hasMarkdown };
}
export const NotionHtmlTransformer = {
importNotionZip,
};

View File

@@ -0,0 +1,115 @@
import { extMimeMap, getAssetName } from '@blocksuite/store';
import * as fflate from 'fflate';
export class Zip {
private compressed = new Uint8Array();
private finalize?: () => void;
private finalized = false;
private readonly zip = new fflate.Zip((err, chunk, final) => {
if (!err) {
const temp = new Uint8Array(this.compressed.length + chunk.length);
temp.set(this.compressed);
temp.set(chunk, this.compressed.length);
this.compressed = temp;
}
if (final) {
this.finalized = true;
this.finalize?.();
}
});
async file(path: string, content: Blob | File | string) {
const deflate = new fflate.ZipDeflate(path);
this.zip.add(deflate);
if (typeof content === 'string') {
deflate.push(fflate.strToU8(content), true);
} else {
deflate.push(new Uint8Array(await content.arrayBuffer()), true);
}
}
folder(folderPath: string) {
return {
folder: (folderPath2: string) => {
return this.folder(`${folderPath}/${folderPath2}`);
},
file: async (name: string, blob: Blob) => {
await this.file(`${folderPath}/${name}`, blob);
},
generate: async () => {
return this.generate();
},
};
}
async generate() {
this.zip.end();
return new Promise<Blob>(resolve => {
if (this.finalized) {
resolve(new Blob([this.compressed], { type: 'application/zip' }));
} else {
this.finalize = () =>
resolve(new Blob([this.compressed], { type: 'application/zip' }));
}
});
}
}
export class Unzip {
private unzipped?: ReturnType<typeof fflate.unzipSync>;
async load(blob: Blob) {
this.unzipped = fflate.unzipSync(new Uint8Array(await blob.arrayBuffer()));
}
*[Symbol.iterator]() {
const keys = Object.keys(this.unzipped ?? {});
let index = 0;
while (keys.length) {
const path = keys.shift()!;
if (path.includes('__MACOSX') || path.includes('DS_Store')) {
continue;
}
const lastSplitIndex = path.lastIndexOf('/');
const fileName = path.substring(lastSplitIndex + 1);
const fileExt =
fileName.lastIndexOf('.') === -1 ? '' : fileName.split('.').at(-1);
const mime = extMimeMap.get(fileExt ?? '');
const content = new File([this.unzipped![path]], fileName, {
type: mime ?? '',
}) as Blob;
yield { path, content, index };
index++;
}
}
}
export async function createAssetsArchive(
assetsMap: Map<string, Blob>,
assetsIds: string[]
) {
const zip = new Zip();
for (const [id, blob] of assetsMap) {
if (!assetsIds.includes(id)) continue;
const name = getAssetName(assetsMap, id);
await zip.folder('assets').file(name, blob);
}
return zip;
}
export function download(blob: Blob, name: string) {
const element = document.createElement('a');
element.setAttribute('download', name);
const fileURL = URL.createObjectURL(blob);
element.setAttribute('href', fileURL);
element.style.display = 'none';
document.body.append(element);
element.click();
element.remove();
URL.revokeObjectURL(fileURL);
}

View File

@@ -0,0 +1,172 @@
import {
replaceIdMiddleware,
titleMiddleware,
} from '@blocksuite/affine-shared/adapters';
import { sha } from '@blocksuite/global/utils';
import type { DocSnapshot, Schema, Store, Workspace } from '@blocksuite/store';
import { extMimeMap, getAssetName, Transformer } from '@blocksuite/store';
import { download, Unzip, Zip } from '../transformers/utils.js';
async function exportDocs(
collection: Workspace,
schema: Schema,
docs: Store[]
) {
const zip = new Zip();
const job = new Transformer({
schema,
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => collection.createDoc(id).getStore({ id }),
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
delete: (id: string) => collection.removeDoc(id),
},
middlewares: [
replaceIdMiddleware(collection.idGenerator),
titleMiddleware(collection.meta.docMetas),
],
});
const snapshots = await Promise.all(docs.map(job.docToSnapshot));
await Promise.all(
snapshots
.filter((snapshot): snapshot is DocSnapshot => !!snapshot)
.map(async snapshot => {
// Use the title and id as the snapshot file name
const title = snapshot.meta.title || 'untitled';
const id = snapshot.meta.id;
const snapshotName = `${title}-${id}.snapshot.json`;
await zip.file(snapshotName, JSON.stringify(snapshot, null, 2));
})
);
const assets = zip.folder('assets');
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
const assetsMap = job.assets;
// Add blobs to assets folder, if failed, log the error and continue
const results = await Promise.all(
Array.from(pathBlobIdMap.values()).map(async blobId => {
try {
await job.assetsManager.readFromBlob(blobId);
const ext = getAssetName(assetsMap, blobId).split('.').at(-1);
const blob = assetsMap.get(blobId);
if (blob) {
await assets.file(`${blobId}.${ext}`, blob);
return { success: true, blobId };
}
return { success: false, blobId, error: 'Blob not found' };
} catch (error) {
console.error(`Failed to process blob: ${blobId}`, error);
return { success: false, blobId, error };
}
})
);
const failures = results.filter(r => !r.success);
if (failures.length > 0) {
console.warn(`Failed to process ${failures.length} blobs:`, failures);
}
const downloadBlob = await zip.generate();
// Use the collection id as the zip file name
return download(downloadBlob, `${collection.id}.bs.zip`);
}
async function importDocs(
collection: Workspace,
schema: Schema,
imported: Blob
) {
const unzip = new Unzip();
await unzip.load(imported);
const assetBlobs: [string, Blob][] = [];
const snapshotsBlobs: Blob[] = [];
for (const { path, content: blob } of unzip) {
if (path.includes('MACOSX') || path.includes('DS_Store')) {
continue;
}
if (path.startsWith('assets/')) {
assetBlobs.push([path, blob]);
continue;
}
if (path === 'info.json') {
continue;
}
if (path.endsWith('.snapshot.json')) {
snapshotsBlobs.push(blob);
continue;
}
}
const job = new Transformer({
schema,
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => collection.createDoc(id).getStore({ id }),
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
delete: (id: string) => collection.removeDoc(id),
},
middlewares: [
replaceIdMiddleware(collection.idGenerator),
titleMiddleware(collection.meta.docMetas),
],
});
const assetsMap = job.assets;
assetBlobs.forEach(([name, blob]) => {
const nameWithExt = name.replace('assets/', '');
const assetsId = nameWithExt.replace(/\.[^/.]+$/, '');
const ext = nameWithExt.split('.').at(-1) ?? '';
const mime = extMimeMap.get(ext) ?? '';
const file = new File([blob], nameWithExt, {
type: mime,
});
assetsMap.set(assetsId, file);
});
return Promise.all(
snapshotsBlobs.map(async blob => {
const json = await blob.text();
const snapshot = JSON.parse(json) as DocSnapshot;
const tasks: Promise<void>[] = [];
job.walk(snapshot, block => {
const sourceId = block.props?.sourceId as string | undefined;
if (sourceId && sourceId.startsWith('/')) {
const removeSlashId = sourceId.replace(/^\//, '');
if (assetsMap.has(removeSlashId)) {
const blob = assetsMap.get(removeSlashId)!;
tasks.push(
blob
.arrayBuffer()
.then(buffer => sha(buffer))
.then(hash => {
assetsMap.set(hash, blob);
block.props.sourceId = hash;
})
);
}
}
});
await Promise.all(tasks);
return job.snapshotToDoc(snapshot);
})
);
}
export const ZipTransformer = {
exportDocs,
importDocs,
};

View File

@@ -0,0 +1,44 @@
import type { AFFINE_DRAG_HANDLE_WIDGET } from '@blocksuite/affine-widget-drag-handle';
import type { AFFINE_FRAME_TITLE_WIDGET } from '@blocksuite/affine-widget-frame-title';
import type {
AFFINE_DOC_REMOTE_SELECTION_WIDGET,
AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET,
} from '@blocksuite/affine-widget-remote-selection';
import type { AFFINE_SLASH_MENU_WIDGET } from '@blocksuite/affine-widget-slash-menu';
import type { EdgelessRootBlockComponent } from './edgeless/edgeless-root-block.js';
import type { PageRootBlockComponent } from './page/page-root-block.js';
import type { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from './widgets/edgeless-zoom-toolbar/index.js';
import type { AFFINE_KEYBOARD_TOOLBAR_WIDGET } from './widgets/index.js';
import type { AFFINE_INNER_MODAL_WIDGET } from './widgets/inner-modal/inner-modal.js';
import type { AFFINE_LINKED_DOC_WIDGET } from './widgets/linked-doc/config.js';
import type { AFFINE_MODAL_WIDGET } from './widgets/modal/modal.js';
import type { AFFINE_PAGE_DRAGGING_AREA_WIDGET } from './widgets/page-dragging-area/page-dragging-area.js';
import type { AFFINE_VIEWPORT_OVERLAY_WIDGET } from './widgets/viewport-overlay/viewport-overlay.js';
export type PageRootBlockWidgetName =
| typeof AFFINE_KEYBOARD_TOOLBAR_WIDGET
| typeof AFFINE_MODAL_WIDGET
| typeof AFFINE_INNER_MODAL_WIDGET
| typeof AFFINE_SLASH_MENU_WIDGET
| typeof AFFINE_LINKED_DOC_WIDGET
| typeof AFFINE_PAGE_DRAGGING_AREA_WIDGET
| typeof AFFINE_DRAG_HANDLE_WIDGET
| typeof AFFINE_DOC_REMOTE_SELECTION_WIDGET
| typeof AFFINE_VIEWPORT_OVERLAY_WIDGET;
export type EdgelessRootBlockWidgetName =
| typeof AFFINE_MODAL_WIDGET
| typeof AFFINE_INNER_MODAL_WIDGET
| typeof AFFINE_SLASH_MENU_WIDGET
| typeof AFFINE_LINKED_DOC_WIDGET
| typeof AFFINE_DRAG_HANDLE_WIDGET
| typeof AFFINE_DOC_REMOTE_SELECTION_WIDGET
| typeof AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET
| typeof AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET
| typeof AFFINE_VIEWPORT_OVERLAY_WIDGET
| typeof AFFINE_FRAME_TITLE_WIDGET;
export type RootBlockComponent =
| PageRootBlockComponent
| EdgelessRootBlockComponent;

View File

@@ -0,0 +1,23 @@
import type { BlockComponent, EditorHost } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
// Run the callback until a model's element updated.
// Please notice that the callback will be called **once the element itself is ready**.
// The children may be not updated.
// If you want to wait for the text elements,
// please use `onModelTextUpdated`.
export async function onModelElementUpdated(
editorHost: EditorHost,
model: BlockModel,
callback: (block: BlockComponent) => void
) {
const page = model.doc;
if (!page.root) return;
const rootComponent = editorHost.view.getBlock(page.root.id);
if (!rootComponent) return;
await rootComponent.updateComplete;
const element = editorHost.view.getBlock(model.id);
if (element) callback(element);
}

View File

@@ -0,0 +1,2 @@
export * from './callback';
export * from './types';

View File

@@ -0,0 +1,7 @@
import type { RootBlockComponent } from '../types.js';
export function getClosestRootBlockComponent(
el: HTMLElement
): RootBlockComponent | null {
return el.closest('affine-edgeless-root, affine-page-root');
}

View File

@@ -0,0 +1,32 @@
import { BookmarkBlockComponent } from '@blocksuite/affine-block-bookmark';
import {
EmbedFigmaBlockComponent,
EmbedGithubBlockComponent,
EmbedHtmlBlockComponent,
EmbedLinkedDocBlockComponent,
EmbedLoomBlockComponent,
EmbedSyncedDocBlockComponent,
EmbedYoutubeBlockComponent,
type LinkableEmbedBlockComponent,
} from '@blocksuite/affine-block-embed';
import type { BlockComponent } from '@blocksuite/std';
export type BuiltInEmbedBlockComponent =
| BookmarkBlockComponent
| LinkableEmbedBlockComponent
| EmbedHtmlBlockComponent;
export function isEmbedCardBlockComponent(
block: BlockComponent
): block is BuiltInEmbedBlockComponent {
return (
block instanceof BookmarkBlockComponent ||
block instanceof EmbedFigmaBlockComponent ||
block instanceof EmbedGithubBlockComponent ||
block instanceof EmbedHtmlBlockComponent ||
block instanceof EmbedLoomBlockComponent ||
block instanceof EmbedYoutubeBlockComponent ||
block instanceof EmbedLinkedDocBlockComponent ||
block instanceof EmbedSyncedDocBlockComponent
);
}

View File

@@ -0,0 +1,123 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import type { FrameBlockModel, RootBlockModel } from '@blocksuite/affine-model';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import { Bound } from '@blocksuite/global/gfx';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { state } from 'lit/decorators.js';
import { literal, unsafeStatic } from 'lit/static-html.js';
export const EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET =
'edgeless-navigator-black-background';
export class EdgelessNavigatorBlackBackgroundWidget extends WidgetComponent<RootBlockModel> {
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 get _slots() {
return this.std.get(EdgelessLegacySlotIdentifier);
}
private _tryLoadBlackBackground() {
const value = this.std
.get(EditPropsStore)
.getStorage('presentBlackBackground');
this._blackBackground = value ?? true;
}
override firstUpdated() {
const { _disposables, gfx } = this;
_disposables.add(
this._slots.navigatorFrameChanged.subscribe(frame => {
this.frame = frame;
})
);
_disposables.add(
this._slots.navigatorSettingUpdated.subscribe(({ blackBackground }) => {
if (blackBackground !== undefined) {
this.std
.get(EditPropsStore)
.setStorage('presentBlackBackground', blackBackground);
this._blackBackground = blackBackground;
this.show =
blackBackground &&
this.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(
this._slots.fullScreenToggled.subscribe(
() =>
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;
}
export const edgelessNavigatorBgWidget = WidgetViewExtension(
'affine:page',
EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET,
literal`${unsafeStatic(EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET)}`
);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
export { AffineEdgelessZoomToolbarWidget } from './edgeless-zoom-toolbar/index.js';
export { AffineInnerModalWidget } from './inner-modal/inner-modal.js';
export * from './keyboard-toolbar/index.js';
export {
type LinkedMenuAction,
type LinkedMenuGroup,
type LinkedMenuItem,
type LinkedWidgetConfig,
LinkedWidgetUtils,
} from './linked-doc/config.js';
export {
// It's used in the AFFiNE!
showImportModal,
} from './linked-doc/import-doc/index.js';
export { AffineLinkedDocWidget } from './linked-doc/index.js';
export { AffineModalWidget } from './modal/modal.js';
export { AffinePageDraggingAreaWidget } from './page-dragging-area/page-dragging-area.js';
export * from './viewport-overlay/viewport-overlay.js';
export { AffineFrameTitleWidget } from '@blocksuite/affine-widget-frame-title';

View File

@@ -0,0 +1,58 @@
import { WidgetComponent } from '@blocksuite/std';
import {
autoUpdate,
computePosition,
type FloatingElement,
type ReferenceElement,
size,
} from '@floating-ui/dom';
import { nothing } from 'lit';
export const AFFINE_INNER_MODAL_WIDGET = 'affine-inner-modal-widget';
export class AffineInnerModalWidget extends WidgetComponent {
private _getTarget?: () => ReferenceElement;
get target(): ReferenceElement {
if (this._getTarget) {
return this._getTarget();
}
return document.body;
}
open(
modal: FloatingElement,
ops: { onClose?: () => void }
): { close(): void } {
const cancel = autoUpdate(this.target, modal, () => {
computePosition(this.target, modal, {
middleware: [
size({
apply: ({ rects }) => {
Object.assign(modal.style, {
left: `${rects.reference.x}px`,
top: `${rects.reference.y}px`,
width: `${rects.reference.width}px`,
height: `${rects.reference.height}px`,
});
},
}),
],
}).catch(console.error);
});
const close = () => {
modal.remove();
ops.onClose?.();
cancel();
};
return { close };
}
override render() {
return nothing;
}
setTarget(fn: () => ReferenceElement) {
this._getTarget = fn;
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,139 @@
import { getDocTitleByEditorHost } from '@blocksuite/affine-fragment-doc-title';
import type { RootBlockModel } from '@blocksuite/affine-model';
import {
FeatureFlagService,
VirtualKeyboardProvider,
type VirtualKeyboardProviderWithAction,
} from '@blocksuite/affine-shared/services';
import { IS_MOBILE } from '@blocksuite/global/env';
import { WidgetComponent } from '@blocksuite/std';
import { effect, signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import type { PageRootBlockComponent } from '../../page/page-root-block.js';
import { RootBlockConfigExtension } from '../../root-config.js';
import { defaultKeyboardToolbarConfig } from './config.js';
export * from './config.js';
export const AFFINE_KEYBOARD_TOOLBAR_WIDGET = 'affine-keyboard-toolbar-widget';
export class AffineKeyboardToolbarWidget extends WidgetComponent<
RootBlockModel,
PageRootBlockComponent
> {
private readonly _close = (blur: boolean) => {
if (blur) {
if (document.activeElement === this._docTitle?.inlineEditorContainer) {
this._docTitle?.inlineEditor?.setInlineRange(null);
this._docTitle?.inlineEditor?.eventSource?.blur();
} else if (document.activeElement === this.block?.rootComponent) {
this.std.selection.clear();
}
}
this._show$.value = false;
};
private readonly _show$ = signal(false);
private _initialInputMode: string = '';
get keyboard(): VirtualKeyboardProviderWithAction {
return {
// fallback keyboard actions
show: () => {
const rootComponent = this.block?.rootComponent;
if (rootComponent && rootComponent === document.activeElement) {
rootComponent.inputMode = this._initialInputMode;
}
},
hide: () => {
const rootComponent = this.block?.rootComponent;
if (rootComponent && rootComponent === document.activeElement) {
rootComponent.inputMode = 'none';
}
},
...this.std.get(VirtualKeyboardProvider),
};
}
private get _docTitle() {
return getDocTitleByEditorHost(this.std.host);
}
get config() {
return {
...defaultKeyboardToolbarConfig,
...this.std.getOptional(RootBlockConfigExtension.identifier)
?.keyboardToolbar,
};
}
override connectedCallback(): void {
super.connectedCallback();
const rootComponent = this.block?.rootComponent;
if (rootComponent) {
this._initialInputMode = rootComponent.inputMode;
this.disposables.add(() => {
rootComponent.inputMode = this._initialInputMode;
});
this.disposables.addFromEvent(rootComponent, 'focus', () => {
this._show$.value = true;
});
this.disposables.addFromEvent(rootComponent, 'blur', () => {
this._show$.value = false;
});
this.disposables.add(
effect(() => {
// recover input mode when keyboard toolbar is hidden
if (!this._show$.value) {
rootComponent.inputMode = this._initialInputMode;
}
})
);
}
if (this._docTitle) {
const { inlineEditorContainer } = this._docTitle;
this.disposables.addFromEvent(inlineEditorContainer, 'focus', () => {
this._show$.value = true;
});
this.disposables.addFromEvent(inlineEditorContainer, 'blur', () => {
this._show$.value = false;
});
}
}
override render() {
if (
this.doc.readonly ||
!IS_MOBILE ||
!this.doc
.get(FeatureFlagService)
.getFlag('enable_mobile_keyboard_toolbar')
)
return nothing;
if (!this._show$.value) return nothing;
if (!this.block?.rootComponent) return nothing;
return html`<blocksuite-portal
.shadowDom=${false}
.template=${html`<affine-keyboard-toolbar
.keyboard=${this.keyboard}
.config=${this.config}
.rootComponent=${this.block.rootComponent}
.close=${this._close}
></affine-keyboard-toolbar>`}
></blocksuite-portal>`;
}
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_KEYBOARD_TOOLBAR_WIDGET]: AffineKeyboardToolbarWidget;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
import { type VirtualKeyboardProvider } from '@blocksuite/affine-shared/services';
import { DisposableGroup } from '@blocksuite/global/disposable';
import type { BlockStdScope, ShadowlessElement } from '@blocksuite/std';
import { effect, type Signal } from '@preact/signals-core';
import type { ReactiveController, ReactiveControllerHost } from 'lit';
/**
* This controller is used to control the keyboard toolbar position
*/
export class PositionController implements ReactiveController {
private readonly _disposables = new DisposableGroup();
host: ReactiveControllerHost &
ShadowlessElement & {
std: BlockStdScope;
panelHeight$: Signal<number>;
keyboard: VirtualKeyboardProvider;
panelOpened: boolean;
};
constructor(host: PositionController['host']) {
(this.host = host).addController(this);
}
hostConnected() {
const { keyboard } = this.host;
this._disposables.add(
effect(() => {
if (keyboard.visible$.value) {
this.host.panelHeight$.value = keyboard.height$.value;
}
})
);
this.host.style.bottom = '0px';
}
hostDisconnected() {
this._disposables.dispose();
}
}

View File

@@ -0,0 +1,145 @@
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css } from 'lit';
export const keyboardToolbarStyles = css`
affine-keyboard-toolbar {
position: fixed;
display: block;
width: 100vw;
}
.keyboard-toolbar {
width: 100%;
height: 46px;
display: inline-flex;
align-items: center;
padding: 0px 8px;
box-sizing: border-box;
gap: 8px;
z-index: var(--affine-z-index-popover);
background-color: ${unsafeCSSVarV2('layer/background/primary')};
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
box-shadow: 0px -4px 10px 0px rgba(0, 0, 0, 0.05);
> div {
padding-top: 4px;
}
> div:not(.item-container) {
padding-bottom: 4px;
}
icon-button svg {
width: 24px;
height: 24px;
}
}
.item-container {
flex: 1;
display: flex;
overflow-x: auto;
gap: 8px;
padding-bottom: 0px;
icon-button {
flex: 0 0 auto;
}
}
.item-container::-webkit-scrollbar {
display: none;
}
.divider {
height: 24px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
`;
export const keyboardToolPanelStyles = css`
affine-keyboard-tool-panel {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
padding: 16px 4px 8px 8px;
overflow-y: auto;
box-sizing: border-box;
background-color: ${unsafeCSSVarV2('layer/background/primary')};
}
${scrollbarStyle('affine-keyboard-tool-panel')}
.keyboard-tool-panel-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
align-self: stretch;
}
.keyboard-tool-panel-group-header {
color: ${unsafeCSSVarV2('text/secondary')};
/* Footnote/Emphasized */
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 13px;
font-style: normal;
font-weight: 590;
line-height: 18px; /* 138.462% */
}
.keyboard-tool-panel-group-item-container {
width: 100%;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 12px;
row-gap: 12px;
}
.keyboard-tool-panel-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
button {
display: flex;
padding: 16px;
justify-content: center;
align-items: center;
gap: 10px;
align-self: stretch;
border: none;
border-radius: 4px;
color: ${unsafeCSSVarV2('icon/primary')};
background: ${unsafeCSSVarV2('layer/background/secondary')};
}
button:active {
background: #00000012;
}
button svg {
width: 32px;
height: 32px;
}
span {
width: 100%;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 18px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow-x: hidden;
color: ${unsafeCSSVarV2('text/secondary')};
}
}
`;

View File

@@ -0,0 +1,43 @@
import type {
KeyboardSubToolbarConfig,
KeyboardToolbarActionItem,
KeyboardToolbarItem,
KeyboardToolPanelConfig,
} from './config.js';
export function isKeyboardToolBarActionItem(
item: KeyboardToolbarItem
): item is KeyboardToolbarActionItem {
return 'action' in item;
}
export function isKeyboardSubToolBarConfig(
item: KeyboardToolbarItem
): item is KeyboardSubToolbarConfig {
return 'items' in item;
}
export function isKeyboardToolPanelConfig(
item: KeyboardToolbarItem
): item is KeyboardToolPanelConfig {
return 'groups' in item;
}
export function formatDate(date: Date) {
// yyyy-mm-dd
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const strTime = `${year}-${month}-${day}`;
return strTime;
}
export function formatTime(date: Date) {
// mm-dd hh:mm
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const strTime = `${month}-${day} ${hours}:${minutes}`;
return strTime;
}

View File

@@ -0,0 +1,262 @@
import {
ImportIcon,
LinkedDocIcon,
LinkedEdgelessIcon,
NewDocIcon,
} from '@blocksuite/affine-components/icons';
import { toast } from '@blocksuite/affine-components/toast';
import { insertLinkedNode } from '@blocksuite/affine-inline-reference';
import {
DocModeProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import {
createDefaultDoc,
isFuzzyMatch,
type Signal,
} from '@blocksuite/affine-shared/utils';
import type { BlockStdScope, EditorHost } from '@blocksuite/std';
import type { InlineRange } from '@blocksuite/std/inline';
import type { TemplateResult } from 'lit';
import { showImportModal } from './import-doc/index.js';
export interface LinkedWidgetConfig {
/**
* The first item of the trigger keys will be the primary key
* e.g. @, [[
*/
triggerKeys: [string, ...string[]];
/**
* Convert trigger key to primary key (the first item of the trigger keys)
* [[ -> @
*/
convertTriggerKey: boolean;
ignoreBlockTypes: string[];
ignoreSelector: string;
getMenus: (
query: string,
abort: () => void,
editorHost: EditorHost,
inlineEditor: AffineInlineEditor,
abortSignal: AbortSignal
) => Promise<LinkedMenuGroup[]> | LinkedMenuGroup[];
/**
* Auto focused item
*
* Will be called when the menu is
* - opened
* - query changed
* - menu group or its items changed
*
* If the return value is not null, no action will be taken.
*/
autoFocusedItemKey?: (
menus: LinkedMenuGroup[],
query: string,
currentActiveKey: string | null,
editorHost: EditorHost,
inlineEditor: AffineInlineEditor
) => string | null;
mobile: {
/**
* The linked doc menu widget will scroll the container to make sure the input cursor is visible in viewport.
* It accepts a selector string, HTMLElement or Window
*
* @default getViewportElement(editorHost) this is the scrollable container in playground
*/
scrollContainer?: string | HTMLElement | Window;
/**
* The offset between the top of viewport and the input cursor
*
* @default 46 The height of header in playground
*/
scrollTopOffset?: number | (() => number);
};
}
export type LinkedMenuItem = {
key: string;
name: string | TemplateResult<1>;
icon: TemplateResult<1>;
suffix?: string | TemplateResult<1>;
// disabled?: boolean;
action: LinkedMenuAction;
};
export type LinkedMenuAction = () => Promise<void> | void;
export type LinkedMenuGroup = {
name: string;
items: LinkedMenuItem[] | Signal<LinkedMenuItem[]>;
styles?: string;
// maximum quantity displayed by default
maxDisplay?: number;
// if the menu is loading
loading?: boolean | Signal<boolean>;
// copywriting when display quantity exceeds
overflowText?: string | Signal<string>;
// hide the group
hidden?: boolean | Signal<boolean>;
};
export type LinkedDocContext = {
std: BlockStdScope;
inlineEditor: AffineInlineEditor;
startRange: InlineRange;
triggerKey: string;
config: LinkedWidgetConfig;
close: () => void;
};
const DEFAULT_DOC_NAME = 'Untitled';
const DISPLAY_NAME_LENGTH = 8;
export function createLinkedDocMenuGroup(
query: string,
abort: () => void,
editorHost: EditorHost,
inlineEditor: AffineInlineEditor
) {
const doc = editorHost.doc;
const { docMetas } = doc.workspace.meta;
const filteredDocList = docMetas
.filter(({ id }) => id !== doc.id)
.filter(({ title }) => isFuzzyMatch(title, query));
const MAX_DOCS = 6;
return {
name: 'Link to Doc',
items: filteredDocList.map(doc => ({
key: doc.id,
name: doc.title || DEFAULT_DOC_NAME,
icon:
editorHost.std.get(DocModeProvider).getPrimaryMode(doc.id) ===
'edgeless'
? LinkedEdgelessIcon
: LinkedDocIcon,
action: () => {
abort();
insertLinkedNode({
inlineEditor,
docId: doc.id,
});
editorHost.std
.getOptional(TelemetryProvider)
?.track('LinkedDocCreated', {
control: 'linked doc',
module: 'inline @',
type: 'doc',
other: 'existing doc',
});
},
})),
maxDisplay: MAX_DOCS,
overflowText: `${filteredDocList.length - MAX_DOCS} more docs`,
};
}
export function createNewDocMenuGroup(
query: string,
abort: () => void,
editorHost: EditorHost,
inlineEditor: AffineInlineEditor
): LinkedMenuGroup {
const doc = editorHost.doc;
const docName = query || DEFAULT_DOC_NAME;
const displayDocName =
docName.slice(0, DISPLAY_NAME_LENGTH) +
(docName.length > DISPLAY_NAME_LENGTH ? '..' : '');
return {
name: 'New Doc',
items: [
{
key: 'create',
name: `Create "${displayDocName}" doc`,
icon: NewDocIcon,
action: () => {
abort();
const docName = query;
const newDoc = createDefaultDoc(doc.workspace, {
title: docName,
});
insertLinkedNode({
inlineEditor,
docId: newDoc.id,
});
const telemetryService =
editorHost.std.getOptional(TelemetryProvider);
telemetryService?.track('LinkedDocCreated', {
control: 'new doc',
module: 'inline @',
type: 'doc',
other: 'new doc',
});
telemetryService?.track('DocCreated', {
control: 'new doc',
module: 'inline @',
type: 'doc',
});
},
},
{
key: 'import',
name: 'Import',
icon: ImportIcon,
action: () => {
abort();
const onSuccess = (
docIds: string[],
options: {
importedCount: number;
}
) => {
toast(
editorHost,
`Successfully imported ${options.importedCount} Doc${options.importedCount > 1 ? 's' : ''}.`
);
for (const docId of docIds) {
insertLinkedNode({
inlineEditor,
docId,
});
}
};
const onFail = (message: string) => {
toast(editorHost, message);
};
showImportModal({
collection: doc.workspace,
schema: doc.schema,
onSuccess,
onFail,
});
},
},
],
};
}
export function getMenus(
query: string,
abort: () => void,
editorHost: EditorHost,
inlineEditor: AffineInlineEditor
): Promise<LinkedMenuGroup[]> {
return Promise.resolve([
createLinkedDocMenuGroup(query, abort, editorHost, inlineEditor),
createNewDocMenuGroup(query, abort, editorHost, inlineEditor),
]);
}
export const LinkedWidgetUtils = {
createLinkedDocMenuGroup,
createNewDocMenuGroup,
insertLinkedNode,
};
export const AFFINE_LINKED_DOC_WIDGET = 'affine-linked-doc-widget';

View File

@@ -0,0 +1,16 @@
import { AFFINE_LINKED_DOC_WIDGET } from './config.js';
import { ImportDoc } from './import-doc/import-doc.js';
import { AffineLinkedDocWidget } from './index.js';
import { LinkedDocPopover } from './linked-doc-popover.js';
import { AffineMobileLinkedDocMenu } from './mobile-linked-doc-menu.js';
export function effects() {
customElements.define('affine-linked-doc-popover', LinkedDocPopover);
customElements.define(AFFINE_LINKED_DOC_WIDGET, AffineLinkedDocWidget);
customElements.define('import-doc', ImportDoc);
customElements.define(
'affine-mobile-linked-doc-menu',
AffineMobileLinkedDocMenu
);
}

View File

@@ -0,0 +1,293 @@
import {
CloseIcon,
ExportToHTMLIcon,
ExportToMarkdownIcon,
HelpIcon,
NewIcon,
NotionIcon,
} from '@blocksuite/affine-components/icons';
import { openFileOrFiles } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import type { Schema, Workspace } from '@blocksuite/store';
import { html, LitElement, type PropertyValues } from 'lit';
import { query, state } from 'lit/decorators.js';
import { HtmlTransformer } from '../../../transformers/html.js';
import { MarkdownTransformer } from '../../../transformers/markdown.js';
import { NotionHtmlTransformer } from '../../../transformers/notion-html.js';
import { styles } from './styles.js';
export type OnSuccessHandler = (
pageIds: string[],
options: { isWorkspaceFile: boolean; importedCount: number }
) => void;
export type OnFailHandler = (message: string) => void;
const SHOW_LOADING_SIZE = 1024 * 200;
export class ImportDoc extends WithDisposable(LitElement) {
static override styles = styles;
constructor(
private readonly collection: Workspace,
private readonly schema: Schema,
private readonly onSuccess?: OnSuccessHandler,
private readonly onFail?: OnFailHandler,
private readonly abortController = new AbortController()
) {
super();
this._loading = false;
this.x = 0;
this.y = 0;
this._startX = 0;
this._startY = 0;
this._onMouseMove = this._onMouseMove.bind(this);
}
private async _importHtml() {
const files = await openFileOrFiles({ acceptType: 'Html', multiple: true });
if (!files) return;
const pageIds: string[] = [];
for (const file of files) {
const text = await file.text();
const needLoading = file.size > SHOW_LOADING_SIZE;
const fileName = file.name.split('.').slice(0, -1).join('.');
if (needLoading) {
this.hidden = false;
this._loading = true;
} else {
this.abortController.abort();
}
const pageId = await HtmlTransformer.importHTMLToDoc({
collection: this.collection,
schema: this.schema,
html: text,
fileName,
});
needLoading && this.abortController.abort();
if (pageId) {
pageIds.push(pageId);
}
}
this._onImportSuccess(pageIds);
}
private async _importMarkDown() {
const files = await openFileOrFiles({
acceptType: 'Markdown',
multiple: true,
});
if (!files) return;
const pageIds: string[] = [];
for (const file of files) {
const text = await file.text();
const fileName = file.name.split('.').slice(0, -1).join('.');
const needLoading = file.size > SHOW_LOADING_SIZE;
if (needLoading) {
this.hidden = false;
this._loading = true;
} else {
this.abortController.abort();
}
const pageId = await MarkdownTransformer.importMarkdownToDoc({
collection: this.collection,
schema: this.schema,
markdown: text,
fileName,
});
needLoading && this.abortController.abort();
if (pageId) {
pageIds.push(pageId);
}
}
this._onImportSuccess(pageIds);
}
private async _importNotion() {
const file = await openFileOrFiles({ acceptType: 'Zip' });
if (!file) return;
const needLoading = file.size > SHOW_LOADING_SIZE;
if (needLoading) {
this.hidden = false;
this._loading = true;
} else {
this.abortController.abort();
}
const { entryId, pageIds, isWorkspaceFile, hasMarkdown } =
await NotionHtmlTransformer.importNotionZip({
collection: this.collection,
schema: this.schema,
imported: file,
});
needLoading && this.abortController.abort();
if (hasMarkdown) {
this._onFail(
'Importing markdown files from Notion is deprecated. Please export your Notion pages as HTML.'
);
return;
}
this._onImportSuccess([entryId], {
isWorkspaceFile,
importedCount: pageIds.length,
});
}
private _onCloseClick(event: MouseEvent) {
event.stopPropagation();
this.abortController.abort();
}
private _onFail(message: string) {
this.onFail?.(message);
}
private _onImportSuccess(
pageIds: string[],
options: { isWorkspaceFile?: boolean; importedCount?: number } = {}
) {
const {
isWorkspaceFile = false,
importedCount: pagesImportedCount = pageIds.length,
} = options;
this.onSuccess?.(pageIds, {
isWorkspaceFile,
importedCount: pagesImportedCount,
});
}
private _onMouseDown(event: MouseEvent) {
this._startX = event.clientX - this.x;
this._startY = event.clientY - this.y;
window.addEventListener('mousemove', this._onMouseMove);
}
private _onMouseMove(event: MouseEvent) {
this.x = event.clientX - this._startX;
this.y = event.clientY - this._startY;
}
private _onMouseUp() {
window.removeEventListener('mousemove', this._onMouseMove);
}
private _openLearnImportLink(event: MouseEvent) {
event.stopPropagation();
window.open(
'https://affine.pro/blog/import-your-data-from-notion-into-affine',
'_blank'
);
}
override render() {
if (this._loading) {
return html`
<div class="overlay-mask"></div>
<div class="container">
<header
class="loading-header"
@mousedown="${this._onMouseDown}"
@mouseup="${this._onMouseUp}"
>
<div>Import</div>
<loader-element .width=${'50px'}></loader-element>
</header>
<div>
Importing the file may take some time. It depends on document size
and complexity.
</div>
</div>
`;
}
return html`
<div
class="overlay-mask"
@click="${() => this.abortController.abort()}"
></div>
<div class="container">
<header @mousedown="${this._onMouseDown}" @mouseup="${this._onMouseUp}">
<icon-button height="28px" @click="${this._onCloseClick}">
${CloseIcon}
</icon-button>
<div>Import</div>
</header>
<div>
AFFiNE will gradually support more file formats for import.
<a
href="https://community.affine.pro/c/feature-requests/import-export"
target="_blank"
>Provide feedback.</a
>
</div>
<div class="button-container">
<icon-button
class="button-item"
text="Markdown"
@click="${this._importMarkDown}"
>
${ExportToMarkdownIcon}
</icon-button>
<icon-button
class="button-item"
text="HTML"
@click="${this._importHtml}"
>
${ExportToHTMLIcon}
</icon-button>
</div>
<div class="button-container">
<icon-button
class="button-item"
text="Notion"
@click="${this._importNotion}"
>
${NotionIcon}
<div
slot="suffix"
class="button-suffix"
@click="${this._openLearnImportLink}"
>
${HelpIcon}
<affine-tooltip>
Learn how to Import your Notion pages into AFFiNE.
</affine-tooltip>
</div>
</icon-button>
<icon-button class="button-item" text="Coming soon..." disabled>
${NewIcon}
</icon-button>
</div>
<!-- <div class="footer">
<div>Migrate from other versions of AFFiNE?</div>
</div> -->
</div>
`;
}
override updated(changedProps: PropertyValues) {
if (changedProps.has('x') || changedProps.has('y')) {
this.containerEl.style.transform = `translate(${this.x}px, ${this.y}px)`;
}
}
@state()
accessor _loading = false;
@state()
accessor _startX = 0;
@state()
accessor _startY = 0;
@query('.container')
accessor containerEl!: HTMLElement;
@state()
accessor x = 0;
@state()
accessor y = 0;
}

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