snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
This commit is contained in:
65
blocksuite/affine/blocks/root/package.json
Normal file
65
blocksuite/affine/blocks/root/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-block-root",
|
||||
"description": "Root block for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-attachment": "workspace:*",
|
||||
"@blocksuite/affine-block-bookmark": "workspace:*",
|
||||
"@blocksuite/affine-block-database": "workspace:*",
|
||||
"@blocksuite/affine-block-edgeless-text": "workspace:*",
|
||||
"@blocksuite/affine-block-embed": "workspace:*",
|
||||
"@blocksuite/affine-block-frame": "workspace:*",
|
||||
"@blocksuite/affine-block-image": "workspace:*",
|
||||
"@blocksuite/affine-block-note": "workspace:*",
|
||||
"@blocksuite/affine-block-paragraph": "workspace:*",
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-gfx-brush": "workspace:*",
|
||||
"@blocksuite/affine-gfx-connector": "workspace:*",
|
||||
"@blocksuite/affine-gfx-group": "workspace:*",
|
||||
"@blocksuite/affine-gfx-mindmap": "workspace:*",
|
||||
"@blocksuite/affine-gfx-note": "workspace:*",
|
||||
"@blocksuite/affine-gfx-pointer": "workspace:*",
|
||||
"@blocksuite/affine-gfx-shape": "workspace:*",
|
||||
"@blocksuite/affine-gfx-text": "workspace:*",
|
||||
"@blocksuite/affine-inline-latex": "workspace:*",
|
||||
"@blocksuite/affine-inline-preset": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||
"@blocksuite/data-view": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"dompurify": "^3.4.11",
|
||||
"html2canvas": "^1.4.1",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"yjs": "^13.6.27"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./effects": "./src/effects.ts",
|
||||
"./store": "./src/store.ts",
|
||||
"./view": "./src/view.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
11
blocksuite/affine/blocks/root/src/adapters/extension.ts
Normal file
11
blocksuite/affine/blocks/root/src/adapters/extension.ts
Normal 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,
|
||||
];
|
||||
135
blocksuite/affine/blocks/root/src/adapters/html.ts
Normal file
135
blocksuite/affine/blocks/root/src/adapters/html.ts
Normal 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
|
||||
);
|
||||
3
blocksuite/affine/blocks/root/src/adapters/index.ts
Normal file
3
blocksuite/affine/blocks/root/src/adapters/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './notion-html.js';
|
||||
36
blocksuite/affine/blocks/root/src/adapters/markdown.ts
Normal file
36
blocksuite/affine/blocks/root/src/adapters/markdown.ts
Normal 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
|
||||
);
|
||||
28
blocksuite/affine/blocks/root/src/adapters/notion-html.ts
Normal file
28
blocksuite/affine/blocks/root/src/adapters/notion-html.ts
Normal 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);
|
||||
2
blocksuite/affine/blocks/root/src/clipboard/index.ts
Normal file
2
blocksuite/affine/blocks/root/src/clipboard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './page-clipboard.js';
|
||||
export * from './readonly-clipboard.js';
|
||||
198
blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts
Normal file
198
blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { deleteTextCommand } from '@blocksuite/affine-inline-preset';
|
||||
import {
|
||||
pasteMiddleware,
|
||||
replaceIdMiddleware,
|
||||
surfaceRefToEmbed,
|
||||
uploadMiddleware,
|
||||
} 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);
|
||||
const upload = uploadMiddleware(this.std);
|
||||
this.std.clipboard.use(paste);
|
||||
this.std.clipboard.use(surfaceRefToEmbedMiddleware);
|
||||
this.std.clipboard.use(replaceId);
|
||||
this.std.clipboard.use(upload);
|
||||
this._disposables.add({
|
||||
dispose: () => {
|
||||
this.std.clipboard.unuse(paste);
|
||||
this.std.clipboard.unuse(surfaceRefToEmbedMiddleware);
|
||||
this.std.clipboard.unuse(replaceId);
|
||||
this.std.clipboard.unuse(upload);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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._copySelectedInPage(() => {
|
||||
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();
|
||||
|
||||
if (this.std.store.readonly) return;
|
||||
this.std.store.captureSync();
|
||||
let hasPasteTarget = false;
|
||||
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;
|
||||
}
|
||||
hasPasteTarget = true;
|
||||
this.std.clipboard
|
||||
.paste(
|
||||
e,
|
||||
this.std.store,
|
||||
ctx.parentBlock.model.id,
|
||||
ctx.blockIndex !== undefined ? ctx.blockIndex + 1 : 1
|
||||
)
|
||||
.catch(console.error);
|
||||
|
||||
return next();
|
||||
})
|
||||
.run();
|
||||
|
||||
if (hasPasteTarget) return;
|
||||
|
||||
// If no valid selection target exists (for example, stale block selection
|
||||
// right after cut), create/focus the default paragraph and paste after it.
|
||||
const firstParagraphId = document
|
||||
.querySelector('affine-page-root')
|
||||
?.focusFirstParagraph?.()?.id;
|
||||
const parentModel = firstParagraphId
|
||||
? this.std.store.getParent(firstParagraphId)
|
||||
: null;
|
||||
const paragraphIndex =
|
||||
firstParagraphId && parentModel
|
||||
? parentModel.children.findIndex(child => child.id === firstParagraphId)
|
||||
: -1;
|
||||
const insertIndex = paragraphIndex >= 0 ? paragraphIndex + 1 : undefined;
|
||||
|
||||
this.std.clipboard
|
||||
.paste(e, this.std.store, parentModel?.id, insertIndex)
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
copyMiddleware,
|
||||
defaultImageProxyMiddleware,
|
||||
titleMiddleware,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import {
|
||||
copySelectedModelsCommand,
|
||||
draftSelectedModelsCommand,
|
||||
getSelectedModelsCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { LifeCycleWatcher, type UIEventHandler } from '@blocksuite/std';
|
||||
|
||||
/**
|
||||
* 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 _copySelectedInPage = (onCopy?: () => void) => {
|
||||
return this.std.command
|
||||
.chain()
|
||||
.with({ onCopy })
|
||||
.pipe(getSelectedModelsCommand, { types: ['block', 'text', 'image'] })
|
||||
.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._copySelectedInPage().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();
|
||||
}
|
||||
}
|
||||
489
blocksuite/affine/blocks/root/src/configs/toolbar.ts
Normal file
489
blocksuite/affine/blocks/root/src/configs/toolbar.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import {
|
||||
convertToDatabase,
|
||||
DATABASE_CONVERT_WHITE_LIST,
|
||||
} from '@blocksuite/affine-block-database';
|
||||
import {
|
||||
convertSelectedBlocksToLinkedDoc,
|
||||
getTitleFromSelectedModels,
|
||||
notifyDocCreated,
|
||||
promptDocTitle,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import {
|
||||
updateBlockAlign,
|
||||
updateBlockType,
|
||||
} from '@blocksuite/affine-block-note';
|
||||
import type { HighlightType } from '@blocksuite/affine-components/highlight-dropdown-menu';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
import { insertInlineLatex } from '@blocksuite/affine-inline-latex';
|
||||
import {
|
||||
deleteTextCommand,
|
||||
formatBlockCommand,
|
||||
formatNativeCommand,
|
||||
formatTextCommand,
|
||||
isFormatSupported,
|
||||
textFormatConfigs,
|
||||
} from '@blocksuite/affine-inline-preset';
|
||||
import {
|
||||
EmbedLinkedDocBlockSchema,
|
||||
EmbedSyncedDocBlockSchema,
|
||||
type TextAlign,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
textAlignConfigs,
|
||||
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,
|
||||
blockCommentToolbarButton,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getMostCommonValue } from '@blocksuite/affine-shared/utils';
|
||||
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
|
||||
import {
|
||||
CopyIcon,
|
||||
DatabaseTableViewIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
LinkedPageIcon,
|
||||
TeXIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import {
|
||||
type BlockComponent,
|
||||
BlockSelection,
|
||||
BlockViewIdentifier,
|
||||
} 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} ${EditorChevronDown}
|
||||
</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 alignActionGroup = {
|
||||
id: 'b.align',
|
||||
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();
|
||||
if (!ok) return null;
|
||||
|
||||
const alignment =
|
||||
textAlignConfigs.find(
|
||||
({ textAlign }) =>
|
||||
textAlign ===
|
||||
getMostCommonValue(
|
||||
selectedModels.map(
|
||||
({ props }) => props as { textAlign?: TextAlign }
|
||||
),
|
||||
'textAlign'
|
||||
)
|
||||
) ?? textAlignConfigs[0];
|
||||
const update = (textAlign: TextAlign) => {
|
||||
chain.pipe(updateBlockAlign, { textAlign }).run();
|
||||
};
|
||||
|
||||
return {
|
||||
content: html`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="Align" .tooltip="${'Align'}">
|
||||
${alignment.icon} ${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${repeat(
|
||||
textAlignConfigs,
|
||||
item => item.name,
|
||||
({ textAlign, name, icon }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${name}
|
||||
@click=${() => update(textAlign)}
|
||||
>
|
||||
${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.flatMap(
|
||||
({ id, name, action, activeWhen, icon }, score) => {
|
||||
const textAction: ToolbarAction = {
|
||||
id,
|
||||
icon,
|
||||
score,
|
||||
tooltip: name,
|
||||
run: ({ host }) => action(host),
|
||||
active: ({ host }) => activeWhen(host),
|
||||
};
|
||||
|
||||
if (id !== 'underline') {
|
||||
return [textAction];
|
||||
}
|
||||
|
||||
return [
|
||||
textAction,
|
||||
{
|
||||
id: 'inline-latex',
|
||||
icon: TeXIcon(),
|
||||
score: score + 0.5,
|
||||
tooltip: 'Inline Equation',
|
||||
run: ({ host }) => {
|
||||
host.std.command
|
||||
.chain()
|
||||
.pipe(getTextSelectionCommand)
|
||||
.pipe(insertInlineLatex)
|
||||
.run();
|
||||
},
|
||||
active: () => false,
|
||||
},
|
||||
];
|
||||
}
|
||||
),
|
||||
} as const satisfies ToolbarActionGroup;
|
||||
|
||||
const highlightActionGroup = {
|
||||
id: 'c.highlight',
|
||||
when: ({ chain }) => isFormatSupported(chain).run()[0],
|
||||
content({ chain }) {
|
||||
const updateHighlight = (styles: HighlightType) => {
|
||||
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: 'e.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: 'f.convert-to-linked-doc',
|
||||
tooltip: 'Create Linked Doc',
|
||||
icon: LinkedPageIcon(),
|
||||
when({ chain, std }) {
|
||||
const supportFlavours = [
|
||||
EmbedLinkedDocBlockSchema,
|
||||
EmbedSyncedDocBlockSchema,
|
||||
].map(schema => schema.model.flavour);
|
||||
if (
|
||||
supportFlavours.some(
|
||||
flavour => !std.getOptional(BlockViewIdentifier(flavour))
|
||||
)
|
||||
)
|
||||
return false;
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
alignActionGroup,
|
||||
inlineTextActionGroup,
|
||||
highlightActionGroup,
|
||||
turnIntoDatabase,
|
||||
turnIntoLinkedDoc,
|
||||
{
|
||||
id: 'g.comment',
|
||||
...blockCommentToolbarButton,
|
||||
},
|
||||
{
|
||||
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(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;
|
||||
117
blocksuite/affine/blocks/root/src/edgeless/clipboard/canvas.ts
Normal file
117
blocksuite/affine/blocks/root/src/edgeless/clipboard/canvas.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
import { addAttachments } from '@blocksuite/affine-block-attachment';
|
||||
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
|
||||
import { addImages } from '@blocksuite/affine-block-image';
|
||||
import {
|
||||
CanvasRenderer,
|
||||
DefaultTool,
|
||||
EdgelessCRUDIdentifier,
|
||||
ExportManager,
|
||||
getSurfaceComponent,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { splitIntoLines } from '@blocksuite/affine-gfx-text';
|
||||
import type {
|
||||
EmbedCardStyle,
|
||||
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 {
|
||||
convertToPng,
|
||||
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, isImageBlock } 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;
|
||||
}
|
||||
|
||||
// Only when an image is selected, it can be pasted normally to page mode.
|
||||
if (elements.length === 1 && isImageBlock(elements[0])) {
|
||||
const element = elements[0];
|
||||
const sourceId = element.props.sourceId$.peek();
|
||||
if (!sourceId) return;
|
||||
|
||||
await this.std.clipboard.writeToClipboard(async items => {
|
||||
const job = this.std.store.getTransformer();
|
||||
await job.assetsManager.readFromBlob(sourceId);
|
||||
|
||||
let blob = job.assetsManager.getAssets().get(sourceId) ?? null;
|
||||
if (!blob) {
|
||||
return items;
|
||||
}
|
||||
|
||||
let type = blob.type;
|
||||
let supported = false;
|
||||
|
||||
try {
|
||||
supported = ClipboardItem?.supports(type) ?? false;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// TODO(@fundon): when converting jpeg to png, image may become larger and exceed the limit.
|
||||
if (!supported) {
|
||||
type = 'image/png';
|
||||
blob = await convertToPng(blob);
|
||||
}
|
||||
|
||||
if (blob) {
|
||||
return {
|
||||
...items,
|
||||
[`${type}`]: blob,
|
||||
};
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
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,
|
||||
shouldTransformPoint: false,
|
||||
});
|
||||
} else {
|
||||
await addAttachments(this.std, [...files], point, false);
|
||||
}
|
||||
|
||||
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 { x, y } = this.toolManager.lastMousePos$.peek();
|
||||
|
||||
// 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: EmbedCardStyle = 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 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);
|
||||
},
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// oxlint-disable-next-line typescript/prefer-for-of
|
||||
for (let i = 0; i < blocksInsideFrame.length; i++) {
|
||||
const element = blocksInsideFrame[i];
|
||||
await _drawTopLevelBlock(element, true);
|
||||
}
|
||||
}
|
||||
|
||||
this._checkCanContinueToCanvas(host, pathname, editorMode);
|
||||
}
|
||||
|
||||
// TODO: handle DOM renderer case for clipboard image generation
|
||||
if (!(this.surface.renderer instanceof CanvasRenderer)) {
|
||||
console.warn(
|
||||
'Skipping canvas generation for clipboard: DOM renderer active.'
|
||||
);
|
||||
return canvas; // Return the empty canvas or handle error
|
||||
}
|
||||
|
||||
const surfaceCanvas = this.surface.renderer.getCanvasByBound(
|
||||
bound,
|
||||
canvasElements
|
||||
);
|
||||
ctx.drawImage(surfaceCanvas, padding, padding, bound.w, bound.h);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (content === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y } = this.toolManager.lastMousePos$.peek();
|
||||
|
||||
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 {
|
||||
// oxlint-disable-next-line typescript/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(DefaultTool);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
218
blocksuite/affine/blocks/root/src/edgeless/clipboard/command.ts
Normal file
218
blocksuite/affine/blocks/root/src/edgeless/clipboard/command.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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 ?? [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,
|
||||
});
|
||||
});
|
||||
}
|
||||
140
blocksuite/affine/blocks/root/src/edgeless/clipboard/utils.ts
Normal file
140
blocksuite/affine/blocks/root/src/edgeless/clipboard/utils.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
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;
|
||||
const allowedTypes = new Set([
|
||||
'Files',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'application/x-moz-file',
|
||||
]);
|
||||
|
||||
return types.includes('Files') && types.every(type => allowedTypes.has(type));
|
||||
}
|
||||
|
||||
export function tryGetSvgFromClipboard(clipboardData: DataTransfer) {
|
||||
try {
|
||||
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;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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[] = [
|
||||
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,
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,382 @@
|
||||
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
getSurfaceComponent,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { ConnectorTool } from '@blocksuite/affine-gfx-connector';
|
||||
import {
|
||||
createGroupCommand,
|
||||
createGroupFromSelectedCommand,
|
||||
ungroupCommand,
|
||||
} from '@blocksuite/affine-gfx-group';
|
||||
import {
|
||||
ConnectorElementModel,
|
||||
DEFAULT_CONNECTOR_MODE,
|
||||
GroupElementModel,
|
||||
MindmapElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
ActionPlacement,
|
||||
blockCommentToolbarButton,
|
||||
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 {
|
||||
batchAddChildren,
|
||||
batchRemoveChildren,
|
||||
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;
|
||||
|
||||
batchRemoveChildren(group, [firstModel]);
|
||||
|
||||
firstModel.index = ctx.gfx.layer.generateIndex();
|
||||
|
||||
const parent = group.group;
|
||||
if (parent && parent instanceof GroupElementModel) {
|
||||
batchAddChildren(parent, [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(ConnectorTool, { mode: DEFAULT_CONNECTOR_MODE });
|
||||
|
||||
const ctc = ctx.gfx.tool.get(ConnectorTool);
|
||||
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 => {
|
||||
if (element.group) {
|
||||
batchRemoveChildren(element.group, [element]);
|
||||
}
|
||||
if (topElement.group) {
|
||||
batchAddChildren(topElement.group, [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),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
placement: ActionPlacement.End,
|
||||
id: 'c.comment',
|
||||
...blockCommentToolbarButton,
|
||||
},
|
||||
|
||||
// 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),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
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,
|
||||
MindmapElementModel,
|
||||
NoteBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type {
|
||||
ToolbarActions,
|
||||
ToolbarContext,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
matchModels,
|
||||
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;
|
||||
|
||||
let placeholder = '';
|
||||
for (const child of model.children) {
|
||||
if (matchModels(child, [ParagraphBlockModel])) {
|
||||
if (child.props.text.length === 0) continue;
|
||||
placeholder = child.props.text.toString();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
const title = await promptDocTitle(ctx.std, placeholder);
|
||||
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, ctx.std);
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const normalizedModels = Array.from(
|
||||
new Map(
|
||||
models.map(model => {
|
||||
const reorderTarget =
|
||||
model.group instanceof MindmapElementModel ? model.group : model;
|
||||
return [reorderTarget.id, reorderTarget];
|
||||
})
|
||||
).values()
|
||||
);
|
||||
|
||||
for (const model of normalizedModels) {
|
||||
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))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { isFrameBlock } from '@blocksuite/affine-block-frame';
|
||||
import { getSurfaceBlock, isNoteBlock } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type FrameBlockModel,
|
||||
type NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
} 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;
|
||||
}
|
||||
blockSnapshot.props.displayMode = NoteDisplayMode.DocAndEdgeless;
|
||||
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.store.workspace.createDoc();
|
||||
const transformer = host.store.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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
753
blocksuite/affine/blocks/root/src/edgeless/edgeless-keyboard.ts
Normal file
753
blocksuite/affine/blocks/root/src/edgeless/edgeless-keyboard.ts
Normal file
@@ -0,0 +1,753 @@
|
||||
import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmark';
|
||||
import { EdgelessTextBlockComponent } from '@blocksuite/affine-block-edgeless-text';
|
||||
import {
|
||||
FrameTool,
|
||||
type PresentToolOption,
|
||||
} from '@blocksuite/affine-block-frame';
|
||||
import {
|
||||
DefaultTool,
|
||||
EdgelessLegacySlotIdentifier,
|
||||
isNoteBlock,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import {
|
||||
BrushTool,
|
||||
EraserTool,
|
||||
HighlighterTool,
|
||||
} from '@blocksuite/affine-gfx-brush';
|
||||
import {
|
||||
ConnectorTool,
|
||||
mountConnectorLabelEditor,
|
||||
} from '@blocksuite/affine-gfx-connector';
|
||||
import {
|
||||
createGroupFromSelectedCommand,
|
||||
ungroupCommand,
|
||||
} from '@blocksuite/affine-gfx-group';
|
||||
import {
|
||||
getNearestTranslation,
|
||||
isElementOutsideViewport,
|
||||
isSingleMindMapNode,
|
||||
} from '@blocksuite/affine-gfx-mindmap';
|
||||
import { NoteTool } from '@blocksuite/affine-gfx-note';
|
||||
import { PanTool } from '@blocksuite/affine-gfx-pointer';
|
||||
import { mountShapeTextEditor, ShapeTool } from '@blocksuite/affine-gfx-shape';
|
||||
import { TextTool } from '@blocksuite/affine-gfx-text';
|
||||
import {
|
||||
ConnectorElementModel,
|
||||
type 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 {
|
||||
type BaseTool,
|
||||
GfxBlockElementModel,
|
||||
GfxControllerIdentifier,
|
||||
type GfxPrimitiveElementModel,
|
||||
isGfxGroupCompatibleModel,
|
||||
type ToolOptions,
|
||||
type ToolType,
|
||||
} 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 { isCanvasElement } from './utils/query.js';
|
||||
|
||||
export class EdgelessPageKeyboardManager extends PageKeyboardManager {
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get slots() {
|
||||
return this.std.get(EdgelessLegacySlotIdentifier);
|
||||
}
|
||||
|
||||
get std() {
|
||||
return this.rootComponent.std;
|
||||
}
|
||||
|
||||
constructor(override rootComponent: EdgelessRootBlockComponent) {
|
||||
super(rootComponent);
|
||||
this.rootComponent.bindHotKey(
|
||||
{
|
||||
v: () => {
|
||||
this._setEdgelessTool(DefaultTool);
|
||||
},
|
||||
t: () => {
|
||||
this._setEdgelessTool(TextTool);
|
||||
},
|
||||
c: () => {
|
||||
const editPropsStore = this.std.get(EditPropsStore);
|
||||
|
||||
let mode: ConnectorMode;
|
||||
if (
|
||||
this.gfx.tool.currentToolName$.peek() === ConnectorTool.toolName
|
||||
) {
|
||||
mode = this.gfx.tool.get(ConnectorTool).getNextMode();
|
||||
editPropsStore.recordLastProps('connector', { mode });
|
||||
} else {
|
||||
mode = editPropsStore.lastProps$.peek().connector.mode;
|
||||
}
|
||||
|
||||
this._setEdgelessTool(ConnectorTool, { mode });
|
||||
},
|
||||
h: () => {
|
||||
this._setEdgelessTool(PanTool, {
|
||||
panning: false,
|
||||
});
|
||||
},
|
||||
n: () => {
|
||||
this._setEdgelessTool(NoteTool, {
|
||||
childFlavour: DEFAULT_NOTE_CHILD_FLAVOUR,
|
||||
childType: DEFAULT_NOTE_CHILD_TYPE,
|
||||
tip: DEFAULT_NOTE_TIP,
|
||||
});
|
||||
},
|
||||
p: () => {
|
||||
this._setEdgelessTool(BrushTool);
|
||||
},
|
||||
'Shift-p': () => {
|
||||
this._setEdgelessTool(HighlighterTool);
|
||||
},
|
||||
e: () => {
|
||||
this._setEdgelessTool(EraserTool);
|
||||
},
|
||||
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,
|
||||
])
|
||||
) {
|
||||
this.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(FrameTool);
|
||||
}
|
||||
},
|
||||
'-': () => {
|
||||
if (this.rootComponent.service.locked) return;
|
||||
const { selectedElements: elements } =
|
||||
rootComponent.service.selection;
|
||||
if (
|
||||
!rootComponent.service.selection.editing &&
|
||||
elements.length === 1 &&
|
||||
isNoteBlock(elements[0])
|
||||
) {
|
||||
this.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;
|
||||
}
|
||||
this._setEdgelessTool(ShapeTool, {
|
||||
shapeName: controller.cycleShapeName('prev'),
|
||||
});
|
||||
|
||||
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.store;
|
||||
|
||||
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);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ 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.flatMap(el =>
|
||||
isGfxGroupCompatibleModel(el) ? el.descendantElements : []
|
||||
),
|
||||
]);
|
||||
|
||||
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<T extends BaseTool>(
|
||||
toolType: ToolType<T>,
|
||||
...options: [options?: ToolOptions<T>, 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(
|
||||
toolType,
|
||||
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') {
|
||||
const toolConstructor = currentTool.constructor as typeof DefaultTool;
|
||||
let finalOptions = currentTool?.activatedOption;
|
||||
|
||||
// Handle frameNavigator (PresentTool) restoration after space pan
|
||||
if (currentTool.toolName === 'frameNavigator') {
|
||||
finalOptions = {
|
||||
...currentTool?.activatedOption,
|
||||
restoredAfterPan: true,
|
||||
} as PresentToolOption;
|
||||
}
|
||||
|
||||
this._setEdgelessTool(toolConstructor, finalOptions);
|
||||
selection.set(currentSel);
|
||||
document.removeEventListener('keyup', revertToPrevTool, false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isKeyDown) {
|
||||
if (
|
||||
currentTool.toolName === 'pan' ||
|
||||
(currentTool.toolName === 'default' && selection.editing)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If in presentation mode, disable black background during space drag
|
||||
if (currentTool.toolName === 'frameNavigator') {
|
||||
this.slots.navigatorSettingUpdated.next({
|
||||
blackBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
this._setEdgelessTool(PanTool, { panning: false });
|
||||
|
||||
this.std.event.disposables.addFromEvent(
|
||||
document,
|
||||
'keyup',
|
||||
revertToPrevTool
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import { NoteConfigExtension } from '@blocksuite/affine-block-note';
|
||||
import {
|
||||
DefaultTool,
|
||||
getBgGridGap,
|
||||
normalizeWheelDeltaY,
|
||||
type SurfaceBlockComponent,
|
||||
type SurfaceBlockModel,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { isSingleMindMapNode } from '@blocksuite/affine-gfx-mindmap';
|
||||
import { PanTool } from '@blocksuite/affine-gfx-pointer';
|
||||
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 { 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
|
||||
> {
|
||||
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 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;
|
||||
}
|
||||
|
||||
get fontLoader() {
|
||||
return this.std.get(FontLoaderService);
|
||||
}
|
||||
|
||||
private _initFontLoader() {
|
||||
this.fontLoader.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.std.event.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.std.event.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), false, true, true);
|
||||
|
||||
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.std.event.add('wheel', ctx => {
|
||||
const config = this.std.getOptional(EditorSettingProvider)?.setting$;
|
||||
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, true, true);
|
||||
e.stopPropagation();
|
||||
}
|
||||
// pan
|
||||
else {
|
||||
// Enable Shift+wheel horizontal scroll on Windows (no native deltaX),
|
||||
// and on any platform when using a mouse without horizontal scroll
|
||||
// (deltaX === 0 means no native horizontal input, e.g. external mouse on macOS/Linux).
|
||||
const simulateHorizontalScroll =
|
||||
e.shiftKey && (IS_WINDOWS || e.deltaX === 0);
|
||||
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.store.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.store.readonly) {
|
||||
this.gfx.tool.setTool(PanTool, { panning: true });
|
||||
} else {
|
||||
this.gfx.tool.setTool(DefaultTool);
|
||||
}
|
||||
|
||||
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.overscanBlockBounds,
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
EdgelessLegacySlotIdentifier,
|
||||
getSurfaceBlock,
|
||||
type SurfaceBlockModel,
|
||||
type SurfaceContext,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type ConnectorElementModel,
|
||||
RootBlockSchema,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { BlockService, type BlockStdScope } from '@blocksuite/std';
|
||||
import type {
|
||||
GfxController,
|
||||
GfxModel,
|
||||
LayerManager,
|
||||
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 { getCursorMode } from './utils/query.js';
|
||||
|
||||
export class EdgelessRootService
|
||||
extends BlockService
|
||||
implements SurfaceContext
|
||||
{
|
||||
static override readonly flavour = RootBlockSchema.model.flavour;
|
||||
|
||||
private readonly _surface: SurfaceBlockModel;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { LifeCycleWatcher } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
|
||||
export class EdgelessLocker extends LifeCycleWatcher {
|
||||
static override key = 'edgeless-locker';
|
||||
|
||||
override mounted() {
|
||||
const { viewport } = this.std.get(GfxControllerIdentifier);
|
||||
viewport.locked = true;
|
||||
}
|
||||
}
|
||||
9
blocksuite/affine/blocks/root/src/edgeless/index.ts
Normal file
9
blocksuite/affine/blocks/root/src/edgeless/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { EdgelessRootPreviewBlockComponent } from '../preview/edgeless-root-preview-block';
|
||||
export * from './clipboard/clipboard';
|
||||
export * from './clipboard/command';
|
||||
export * from './edgeless-root-block.js';
|
||||
export { EdgelessRootService } from './edgeless-root-service.js';
|
||||
export * from './utils/clipboard-utils.js';
|
||||
export { getElementProps, sortEdgelessElements } from './utils/clone-utils.js';
|
||||
export { isCanvasElement } from './utils/query.js';
|
||||
export { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getCommonBoundWithRotation } from '@blocksuite/global/gfx';
|
||||
import { type GfxModel, InteractivityExtension } from '@blocksuite/std/gfx';
|
||||
|
||||
import { createElementsFromClipboardDataCommand } from '../clipboard/command.js';
|
||||
import { prepareCloneData } from '../utils/clone-utils.js';
|
||||
|
||||
export class AltCloneExtension extends InteractivityExtension {
|
||||
static override key = 'alt-clone';
|
||||
|
||||
override mounted(): void {
|
||||
this.action.onRequestElementsClone(async context => {
|
||||
const { elements: elementsToClone } = context;
|
||||
const snapshot = prepareCloneData(elementsToClone, this.std);
|
||||
|
||||
const bound = getCommonBoundWithRotation(elementsToClone);
|
||||
const [_, { createdElementsPromise }] = this.std.command.exec(
|
||||
createElementsFromClipboardDataCommand,
|
||||
{
|
||||
elementsRawData: snapshot,
|
||||
pasteCenter: bound.center,
|
||||
}
|
||||
);
|
||||
|
||||
if (!createdElementsPromise) return;
|
||||
const { canvasElements, blockModels } = await createdElementsPromise;
|
||||
|
||||
return {
|
||||
elements: [...canvasElements, ...blockModels] as GfxModel[],
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
};
|
||||
237
blocksuite/affine/blocks/root/src/edgeless/utils/clone-utils.ts
Normal file
237
blocksuite/affine/blocks/root/src/edgeless/utils/clone-utils.ts
Normal 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();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
getSurfaceBlock,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
std: BlockStdScope
|
||||
) {
|
||||
const model = getSurfaceBlock(std.store);
|
||||
if (!model) return;
|
||||
const connectors = model.getConnectors(originId);
|
||||
const crud = 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 },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const DEFAULT_NOTE_CHILD_FLAVOUR = 'affine:paragraph';
|
||||
export const DEFAULT_NOTE_CHILD_TYPE = 'text';
|
||||
export const DEFAULT_NOTE_TIP = 'Text';
|
||||
37
blocksuite/affine/blocks/root/src/edgeless/utils/crud.ts
Normal file
37
blocksuite/affine/blocks/root/src/edgeless/utils/crud.ts
Normal 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.store.root?.children ?? [];
|
||||
// FIXME: should always keep at least 1 note
|
||||
if (children.length > 1) {
|
||||
edgeless.store.deleteBlock(element);
|
||||
}
|
||||
} else {
|
||||
service.removeElement(element.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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`;
|
||||
104
blocksuite/affine/blocks/root/src/edgeless/utils/query.ts
Normal file
104
blocksuite/affine/blocks/root/src/edgeless/utils/query.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { CanvasElementWithText } from '@blocksuite/affine-block-surface';
|
||||
import type { PanTool } from '@blocksuite/affine-gfx-pointer';
|
||||
import {
|
||||
type AttachmentBlockModel,
|
||||
type Connectable,
|
||||
type EdgelessTextBlockModel,
|
||||
type EmbedSyncedDocModel,
|
||||
type ImageBlockModel,
|
||||
ShapeElementModel,
|
||||
TextElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { isTopLevelBlock } from '@blocksuite/affine-shared/utils';
|
||||
import type {
|
||||
GfxModel,
|
||||
GfxPrimitiveElementModel,
|
||||
ToolOptionWithType,
|
||||
} 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 isEmbedSyncedDocBlock(
|
||||
element: BlockModel | GfxModel | null
|
||||
): element is EmbedSyncedDocModel {
|
||||
return (
|
||||
!!element &&
|
||||
'flavour' in element &&
|
||||
element.flavour === 'affine:embed-synced-doc'
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/cursor
|
||||
export function getCursorMode(edgelessTool: ToolOptionWithType) {
|
||||
if (!edgelessTool) {
|
||||
return 'default';
|
||||
}
|
||||
switch (edgelessTool.toolType?.toolName) {
|
||||
case 'default':
|
||||
return 'default';
|
||||
case 'pan':
|
||||
return (edgelessTool as ToolOptionWithType<PanTool>).options?.panning
|
||||
? 'grabbing'
|
||||
: 'grab';
|
||||
case 'brush':
|
||||
case 'highlighter':
|
||||
return drawingCursor;
|
||||
case 'eraser':
|
||||
case 'shape':
|
||||
case 'connector':
|
||||
case 'frame':
|
||||
case 'affine:note':
|
||||
return 'crosshair';
|
||||
case 'text':
|
||||
return 'text';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
28
blocksuite/affine/blocks/root/src/effects.ts
Normal file
28
blocksuite/affine/blocks/root/src/effects.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
EdgelessRootBlockComponent,
|
||||
EdgelessRootPreviewBlockComponent,
|
||||
PageRootBlockComponent,
|
||||
PreviewRootBlockComponent,
|
||||
} from './index.js';
|
||||
|
||||
export function effects() {
|
||||
// Register components by category
|
||||
registerRootComponents();
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-edgeless-root': EdgelessRootBlockComponent;
|
||||
'affine-page-root': PageRootBlockComponent;
|
||||
}
|
||||
}
|
||||
7
blocksuite/affine/blocks/root/src/index.ts
Normal file
7
blocksuite/affine/blocks/root/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './adapters';
|
||||
export * from './clipboard/index.js';
|
||||
export * from './edgeless/edgeless-root-spec.js';
|
||||
export * from './edgeless/index.js';
|
||||
export * from './page/page-root-block.js';
|
||||
export * from './preview/preview-root-block.js';
|
||||
export * from './types.js';
|
||||
163
blocksuite/affine/blocks/root/src/keyboard/keyboard-manager.ts
Normal file
163
blocksuite/affine/blocks/root/src/keyboard/keyboard-manager.ts
Normal 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.store;
|
||||
}
|
||||
|
||||
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.store;
|
||||
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);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
16
blocksuite/affine/blocks/root/src/keyboard/keymap.ts
Normal file
16
blocksuite/affine/blocks/root/src/keyboard/keymap.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { KeymapExtension } from '@blocksuite/std';
|
||||
|
||||
export const fallbackKeymap = KeymapExtension(() => {
|
||||
return {
|
||||
Tab: ctx => {
|
||||
const event = ctx.get('defaultState').event;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
},
|
||||
'Shift-Tab': ctx => {
|
||||
const event = ctx.get('defaultState').event;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
},
|
||||
};
|
||||
});
|
||||
429
blocksuite/affine/blocks/root/src/page/page-root-block.ts
Normal file
429
blocksuite/affine/blocks/root/src/page/page-root-block.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
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 { IS_ANDROID } from '@blocksuite/global/env';
|
||||
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 { PageKeyboardManager } from '../keyboard/keyboard-manager.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> {
|
||||
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.store.addBlock(
|
||||
'affine:paragraph',
|
||||
{},
|
||||
defaultNote,
|
||||
0
|
||||
);
|
||||
focusTextModel(this.std, newFirstParagraphId);
|
||||
return { id: newFirstParagraphId, created: true };
|
||||
}
|
||||
};
|
||||
|
||||
keyboardManager: PageKeyboardManager | null = null;
|
||||
|
||||
prependParagraphWithText = (text: Text) => {
|
||||
const newFirstParagraphId = this.store.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 { store } = this;
|
||||
|
||||
const noteId = store.addBlock('affine:note', {}, store.root?.id);
|
||||
return store.getModelById(noteId) as NoteBlockModel;
|
||||
}
|
||||
|
||||
private _getDefaultNoteBlock() {
|
||||
return (
|
||||
this.store.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.store.getModelById(sel.blockId);
|
||||
while (current && !model) {
|
||||
if (current.flavour === 'affine:note') {
|
||||
model = current;
|
||||
} else {
|
||||
current = this.store.getParent(current);
|
||||
}
|
||||
}
|
||||
if (!model) return;
|
||||
const prevNote = this.store.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.store.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 (
|
||||
!this.store.readonly$.value &&
|
||||
(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 && !this.store.readonly$.value) {
|
||||
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);
|
||||
});
|
||||
|
||||
// Android IMEs can target this outer editable root instead of a block's
|
||||
// inline editor, leaving composition text in the DOM without committing it
|
||||
// to the document model. Keep only the block editors editable on Android.
|
||||
this.contentEditable = String(!this.store.readonly$.value && !IS_ANDROID);
|
||||
|
||||
return html`
|
||||
<div class="affine-page-root-block-container">${children} ${widgets}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.affine-page-root-block-container')
|
||||
accessor rootElementContainer!: HTMLDivElement;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
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 { isCanvasElement } from '../edgeless/utils/query';
|
||||
|
||||
export class EdgelessRootPreviewBlockComponent extends BlockComponent<RootBlockModel> {
|
||||
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 get _viewport() {
|
||||
return this._gfx.viewport;
|
||||
}
|
||||
|
||||
private readonly _refreshLayerViewport = requestThrottledConnectedFrame(
|
||||
() => {
|
||||
const { zoom, translateX, translateY } = this._viewport;
|
||||
const gap = getBgGridGap(zoom);
|
||||
|
||||
this.backgroundStyle = {
|
||||
backgroundPosition: `${translateX}px ${translateY}px`,
|
||||
backgroundSize: `${gap}px ${gap}px`,
|
||||
};
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
private _resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
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)?.setting$;
|
||||
|
||||
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.overscanBlockBounds,
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
17
blocksuite/affine/blocks/root/src/store.ts
Normal file
17
blocksuite/affine/blocks/root/src/store.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
type StoreExtensionContext,
|
||||
StoreExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
import { RootBlockSchemaExtension } from '@blocksuite/affine-model';
|
||||
|
||||
import { RootBlockAdapterExtensions } from './adapters/extension';
|
||||
|
||||
export class RootStoreExtension extends StoreExtensionProvider {
|
||||
override name = 'affine-root-block';
|
||||
|
||||
override setup(context: StoreExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(RootBlockSchemaExtension);
|
||||
context.register(RootBlockAdapterExtensions);
|
||||
}
|
||||
}
|
||||
6
blocksuite/affine/blocks/root/src/types.ts
Normal file
6
blocksuite/affine/blocks/root/src/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { EdgelessRootBlockComponent } from './edgeless/edgeless-root-block.js';
|
||||
import type { PageRootBlockComponent } from './page/page-root-block.js';
|
||||
|
||||
export type RootBlockComponent =
|
||||
| PageRootBlockComponent
|
||||
| EdgelessRootBlockComponent;
|
||||
93
blocksuite/affine/blocks/root/src/view.ts
Normal file
93
blocksuite/affine/blocks/root/src/view.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
import { NoteBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ToolbarModuleExtension,
|
||||
ViewportElementExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
BlockFlavourIdentifier,
|
||||
BlockViewExtension,
|
||||
FlavourExtension,
|
||||
} from '@blocksuite/std';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { PageClipboard, ReadOnlyClipboard } from './clipboard';
|
||||
import { builtinToolbarConfig } from './configs/toolbar';
|
||||
import { EdgelessClipboardController, EdgelessRootService } from './edgeless';
|
||||
import { EdgelessElementToolbarExtension } from './edgeless/configs/toolbar';
|
||||
import { EdgelessLocker } from './edgeless/edgeless-root-spec';
|
||||
import { AltCloneExtension } from './edgeless/interact-extensions/clone-ext';
|
||||
import { effects } from './effects';
|
||||
import { fallbackKeymap } from './keyboard/keymap';
|
||||
|
||||
export class RootViewExtension extends ViewExtensionProvider {
|
||||
override name = 'affine-root-block';
|
||||
|
||||
override effect(): void {
|
||||
super.effect();
|
||||
effects();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register([
|
||||
FlavourExtension('affine:page'),
|
||||
fallbackKeymap,
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(NoteBlockSchema.model.flavour),
|
||||
config: builtinToolbarConfig,
|
||||
}),
|
||||
]);
|
||||
if (
|
||||
context.scope === 'preview-page' ||
|
||||
context.scope === 'preview-edgeless'
|
||||
) {
|
||||
context.register(ReadOnlyClipboard);
|
||||
}
|
||||
if (this.isEdgeless(context.scope)) {
|
||||
this._setupEdgeless(context);
|
||||
return;
|
||||
}
|
||||
this._setupPage(context);
|
||||
}
|
||||
|
||||
private readonly _setupPage = (context: ViewExtensionContext) => {
|
||||
context.register(ViewportElementExtension('.affine-page-viewport'));
|
||||
if (context.scope === 'preview-page') {
|
||||
context.register(
|
||||
BlockViewExtension('affine:page', literal`affine-preview-root`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.register(
|
||||
BlockViewExtension('affine:page', literal`affine-page-root`)
|
||||
);
|
||||
context.register(PageClipboard);
|
||||
};
|
||||
|
||||
private readonly _setupEdgeless = (context: ViewExtensionContext) => {
|
||||
context.register([
|
||||
EdgelessRootService,
|
||||
ViewportElementExtension('.affine-edgeless-viewport'),
|
||||
]);
|
||||
if (context.scope === 'preview-edgeless') {
|
||||
context.register([
|
||||
BlockViewExtension(
|
||||
'affine:page',
|
||||
literal`affine-edgeless-root-preview`
|
||||
),
|
||||
EdgelessLocker,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
context.register([
|
||||
BlockViewExtension('affine:page', literal`affine-edgeless-root`),
|
||||
EdgelessClipboardController,
|
||||
AltCloneExtension,
|
||||
]);
|
||||
context.register(EdgelessElementToolbarExtension);
|
||||
};
|
||||
}
|
||||
41
blocksuite/affine/blocks/root/tsconfig.json
Normal file
41
blocksuite/affine/blocks/root/tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../attachment" },
|
||||
{ "path": "../bookmark" },
|
||||
{ "path": "../database" },
|
||||
{ "path": "../edgeless-text" },
|
||||
{ "path": "../embed" },
|
||||
{ "path": "../frame" },
|
||||
{ "path": "../image" },
|
||||
{ "path": "../note" },
|
||||
{ "path": "../paragraph" },
|
||||
{ "path": "../surface" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../../gfx/brush" },
|
||||
{ "path": "../../gfx/connector" },
|
||||
{ "path": "../../gfx/group" },
|
||||
{ "path": "../../gfx/mindmap" },
|
||||
{ "path": "../../gfx/note" },
|
||||
{ "path": "../../gfx/pointer" },
|
||||
{ "path": "../../gfx/shape" },
|
||||
{ "path": "../../gfx/text" },
|
||||
{ "path": "../../inlines/latex" },
|
||||
{ "path": "../../inlines/preset" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/edgeless-toolbar" },
|
||||
{ "path": "../../data-view" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" },
|
||||
{ "path": "../../../framework/store" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user