snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
40
blocksuite/affine/widgets/toolbar/package.json
Normal file
40
blocksuite/affine/widgets/toolbar/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-widget-toolbar",
|
||||
"description": "Affine toolbar widget.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-database": "workspace:*",
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-block-table": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.23"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./view": "./src/view.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
11
blocksuite/affine/widgets/toolbar/src/effects.ts
Normal file
11
blocksuite/affine/widgets/toolbar/src/effects.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AFFINE_TOOLBAR_WIDGET, AffineToolbarWidget } from './toolbar';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(AFFINE_TOOLBAR_WIDGET, AffineToolbarWidget);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_TOOLBAR_WIDGET]: AffineToolbarWidget;
|
||||
}
|
||||
}
|
||||
12
blocksuite/affine/widgets/toolbar/src/index.ts
Normal file
12
blocksuite/affine/widgets/toolbar/src/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { WidgetViewExtension } from '@blocksuite/std';
|
||||
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||
|
||||
import { AFFINE_TOOLBAR_WIDGET } from './toolbar';
|
||||
|
||||
export * from './toolbar';
|
||||
|
||||
export const toolbarWidget = WidgetViewExtension(
|
||||
'affine:page',
|
||||
AFFINE_TOOLBAR_WIDGET,
|
||||
literal`${unsafeStatic(AFFINE_TOOLBAR_WIDGET)}`
|
||||
);
|
||||
818
blocksuite/affine/widgets/toolbar/src/toolbar.ts
Normal file
818
blocksuite/affine/widgets/toolbar/src/toolbar.ts
Normal file
@@ -0,0 +1,818 @@
|
||||
import { DatabaseSelection } from '@blocksuite/affine-block-database';
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import { TableSelection } from '@blocksuite/affine-block-table';
|
||||
import {
|
||||
darkToolbarStyles,
|
||||
type EditorMenuButton,
|
||||
EditorToolbar,
|
||||
lightToolbarStyles,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
CodeBlockModel,
|
||||
ImageBlockModel,
|
||||
ListBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
ToolbarContext,
|
||||
ToolbarFlag as Flag,
|
||||
ToolbarRegistryIdentifier,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import {
|
||||
Bound,
|
||||
getCommonBound,
|
||||
getCommonBoundWithRotation,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { nextTick } from '@blocksuite/global/utils';
|
||||
import {
|
||||
type BlockComponent,
|
||||
BlockSelection,
|
||||
TextSelection,
|
||||
WidgetComponent,
|
||||
} from '@blocksuite/std';
|
||||
import {
|
||||
GfxBlockElementModel,
|
||||
type GfxController,
|
||||
type GfxModel,
|
||||
GfxPrimitiveElementModel,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
|
||||
import type { ReferenceElement, SideObject } from '@floating-ui/dom';
|
||||
import { batch, effect, signal } from '@preact/signals-core';
|
||||
import { css, unsafeCSS } from 'lit';
|
||||
import groupBy from 'lodash-es/groupBy';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import toPairs from 'lodash-es/toPairs';
|
||||
|
||||
import { autoUpdatePosition, renderToolbar, sideMap } from './utils';
|
||||
|
||||
export const AFFINE_TOOLBAR_WIDGET = 'affine-toolbar-widget';
|
||||
|
||||
export class AffineToolbarWidget extends WidgetComponent {
|
||||
static override styles = css`
|
||||
editor-toolbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
display: none;
|
||||
width: max-content;
|
||||
touch-action: none;
|
||||
backface-visibility: hidden;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
|
||||
will-change: opacity, overlay, display, transform;
|
||||
transition-property: opacity, overlay, display;
|
||||
transition-duration: 120ms;
|
||||
transition-timing-function: ease-out;
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
editor-toolbar[data-open] {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transition-timing-function: ease-in;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
editor-toolbar[data-open][data-inline='true'] {
|
||||
transition-property: opacity, overlay, display, transform;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
editor-toolbar[data-placement='inner'] {
|
||||
background-color: unset;
|
||||
box-shadow: unset;
|
||||
height: fit-content;
|
||||
padding-top: 4px;
|
||||
border-radius: 0;
|
||||
border: unset;
|
||||
justify-content: flex-end;
|
||||
box-sizing: border-box;
|
||||
gap: 4px;
|
||||
|
||||
.inner-button,
|
||||
editor-icon-button,
|
||||
editor-menu-button {
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
box-shadow: ${unsafeCSSVar('buttonShadow')};
|
||||
background: ${unsafeCSSVar('white')};
|
||||
border-radius: 4px;
|
||||
}
|
||||
editor-menu-button > div {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
editor-toolbar[data-mobile='true'] {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
left: 50%;
|
||||
bottom: 16px;
|
||||
transform: translateX(-50%);
|
||||
max-width: calc(100vw - 32px);
|
||||
overflow-x: auto;
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
${unsafeCSS(darkToolbarStyles('editor-toolbar'))}
|
||||
${unsafeCSS(lightToolbarStyles('editor-toolbar'))}
|
||||
`;
|
||||
|
||||
sideOptions$ = signal<Partial<SideObject> | null>(null);
|
||||
|
||||
referenceElement$ = signal<(() => ReferenceElement | null) | null>(null);
|
||||
|
||||
setReferenceElementWithRange(range: Range | null) {
|
||||
this.referenceElement$.value = range
|
||||
? () => ({
|
||||
getBoundingClientRect: () => range.getBoundingClientRect(),
|
||||
getClientRects: () =>
|
||||
Array.from(range.getClientRects()).filter(rect =>
|
||||
Math.round(rect.width)
|
||||
),
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
setReferenceElementWithHtmlElement(element: Element | null) {
|
||||
this.referenceElement$.value = element ? () => element : null;
|
||||
}
|
||||
|
||||
setReferenceElementWithBlocks(blocks: BlockComponent[]) {
|
||||
let cachedClientRects: DOMRect[] | null = null;
|
||||
const getClientRects = () => {
|
||||
if (!cachedClientRects) {
|
||||
cachedClientRects = blocks.map(e => e.getBoundingClientRect());
|
||||
requestAnimationFrame(() => {
|
||||
cachedClientRects = null;
|
||||
});
|
||||
}
|
||||
return cachedClientRects;
|
||||
};
|
||||
|
||||
this.referenceElement$.value = blocks.length
|
||||
? () => ({
|
||||
getBoundingClientRect: () => {
|
||||
const rects = getClientRects();
|
||||
const bounds = getCommonBound(rects.map(Bound.fromDOMRect));
|
||||
if (!bounds) return rects[0];
|
||||
return new DOMRect(bounds.x, bounds.y, bounds.w, bounds.h);
|
||||
},
|
||||
getClientRects,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
setReferenceElementWithElements(gfx: GfxController, elements: GfxModel[]) {
|
||||
const surfaceBounds = getCommonBoundWithRotation(elements);
|
||||
|
||||
const getBoundingClientRect = () => {
|
||||
const { x: offsetX, y: offsetY } = this.getBoundingClientRect();
|
||||
const [x, y, w, h] = gfx.viewport.toViewBound(surfaceBounds).toXYWH();
|
||||
const rect = new DOMRect(x + offsetX, y + offsetY, w, h);
|
||||
return rect;
|
||||
};
|
||||
|
||||
this.referenceElement$.value = elements.length
|
||||
? () => ({
|
||||
getBoundingClientRect,
|
||||
getClientRects: () => [getBoundingClientRect()],
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
updateWithSurface(
|
||||
ctx: ToolbarContext,
|
||||
activated: boolean,
|
||||
elementIds: string[]
|
||||
) {
|
||||
const gfx = ctx.gfx;
|
||||
const surface = gfx.surface;
|
||||
let flavour = 'affine:surface';
|
||||
let elements: GfxModel[] = [];
|
||||
let hasLocked = false;
|
||||
let sideOptions = null;
|
||||
let paired: [string, GfxModel[]][] = [];
|
||||
|
||||
if (activated && surface) {
|
||||
elements = elementIds
|
||||
.map(id => gfx.getElementById(id))
|
||||
.filter(model => model !== null) as GfxModel[];
|
||||
|
||||
// Should double check
|
||||
activated &&= Boolean(elements.length);
|
||||
|
||||
hasLocked = elements.some(e => e.isLocked());
|
||||
|
||||
const grouped = groupBy(
|
||||
elements.map(model => {
|
||||
let flavour = surface.flavour;
|
||||
|
||||
if (model instanceof GfxBlockElementModel) {
|
||||
flavour += `:${model.flavour.split(':').pop()}`;
|
||||
} else if (model instanceof GfxPrimitiveElementModel) {
|
||||
flavour += `:${model.type}`;
|
||||
}
|
||||
|
||||
return { model, flavour };
|
||||
}),
|
||||
e => e.flavour
|
||||
);
|
||||
|
||||
paired = toPairs(grouped).map(([flavour, items]) => [
|
||||
flavour,
|
||||
items.map(({ model }) => model),
|
||||
]);
|
||||
|
||||
if (hasLocked) {
|
||||
flavour = 'affine:surface:locked';
|
||||
} else {
|
||||
if (paired.length === 1) {
|
||||
flavour = paired[0][0];
|
||||
if (flavour === 'affine:surface:shape' && paired[0][1].length === 1) {
|
||||
sideOptions = sideMap.get(flavour) ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sideOptions) {
|
||||
const flavours = new Set(paired.map(([f]) => f));
|
||||
if (flavours.has('affine:surface:frame')) {
|
||||
sideOptions = sideMap.get('affine:surface:frame') ?? null;
|
||||
} else if (flavours.has('affine:surface:group')) {
|
||||
sideOptions = sideMap.get('affine:surface:group') ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
ctx.flags.toggle(Flag.Surface, activated);
|
||||
|
||||
ctx.elementsMap$.value = new Map(paired);
|
||||
|
||||
if (!activated || !flavour) return;
|
||||
|
||||
this.setReferenceElementWithElements(gfx, elements);
|
||||
|
||||
this.sideOptions$.value = sideOptions;
|
||||
ctx.flavour$.value = flavour;
|
||||
ctx.placement$.value = hasLocked ? 'top' : 'top-start';
|
||||
ctx.flags.refresh(Flag.Surface);
|
||||
});
|
||||
}
|
||||
|
||||
toolbar = new EditorToolbar();
|
||||
|
||||
get toolbarRegistry() {
|
||||
return this.std.get(ToolbarRegistryIdentifier);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
|
||||
const {
|
||||
sideOptions$,
|
||||
referenceElement$,
|
||||
disposables,
|
||||
toolbar,
|
||||
toolbarRegistry,
|
||||
host,
|
||||
std,
|
||||
} = this;
|
||||
const { flags, flavour$, message$, placement$ } = toolbarRegistry;
|
||||
const context = new ToolbarContext(std);
|
||||
|
||||
const isNativeTextSelection = () => {
|
||||
const dbSel = std.selection.find(DatabaseSelection);
|
||||
const dbViewSel = dbSel?.viewSelection;
|
||||
if (
|
||||
dbViewSel &&
|
||||
((dbViewSel.selectionType === 'area' && dbViewSel.isEditing) ||
|
||||
(dbViewSel.selectionType === 'cell' && dbViewSel.isEditing))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tableViewSelection = std.selection.find(TableSelection)?.data;
|
||||
return tableViewSelection?.type === 'area';
|
||||
};
|
||||
|
||||
let updateMobilePosition: (() => void) | null = null;
|
||||
|
||||
if (IS_MOBILE) {
|
||||
toolbar.dataset.mobile = 'true';
|
||||
this.shadowRoot!.append(toolbar);
|
||||
|
||||
// Position toolbar above virtual keyboard using Visual Viewport API
|
||||
updateMobilePosition = () => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const keyboardHeight = window.innerHeight - vv.height - vv.offsetTop;
|
||||
toolbar.style.bottom = `${Math.max(16, keyboardHeight + 16)}px`;
|
||||
};
|
||||
if (window.visualViewport) {
|
||||
disposables.addFromEvent(
|
||||
window.visualViewport,
|
||||
'resize',
|
||||
updateMobilePosition
|
||||
);
|
||||
disposables.addFromEvent(
|
||||
window.visualViewport,
|
||||
'scroll',
|
||||
updateMobilePosition
|
||||
);
|
||||
}
|
||||
|
||||
// Keep mobile selection in sync with toolbar flags. On some mobile browsers,
|
||||
// long-press selection may skip the std selection stream intermittently.
|
||||
const syncMobileTextSelection = () => {
|
||||
if (!context.activated) {
|
||||
flags.toggle(Flag.Text, false);
|
||||
return;
|
||||
}
|
||||
if (isNativeTextSelection()) {
|
||||
flags.toggle(Flag.Text, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const hasSelection =
|
||||
selection &&
|
||||
selection.rangeCount > 0 &&
|
||||
!selection.isCollapsed &&
|
||||
selection.toString().length > 0;
|
||||
const range = hasSelection ? selection.getRangeAt(0) : null;
|
||||
const inEditor = Boolean(
|
||||
range && host.contains(range.commonAncestorContainer)
|
||||
);
|
||||
|
||||
batch(() => {
|
||||
flags.toggle(Flag.Text, inEditor);
|
||||
|
||||
if (!inEditor || !range) return;
|
||||
|
||||
this.setReferenceElementWithRange(range);
|
||||
|
||||
sideOptions$.value = null;
|
||||
flavour$.value = 'affine:note';
|
||||
placement$.value = toolbarRegistry.getModulePlacement('affine:note');
|
||||
flags.refresh(Flag.Text);
|
||||
});
|
||||
};
|
||||
|
||||
let selectionTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let touchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const scheduleSyncMobileTextSelection = (delay: number) => {
|
||||
if (selectionTimeout) clearTimeout(selectionTimeout);
|
||||
selectionTimeout = setTimeout(syncMobileTextSelection, delay);
|
||||
};
|
||||
const scheduleTouchSync = (delay: number) => {
|
||||
if (touchTimeout) clearTimeout(touchTimeout);
|
||||
touchTimeout = setTimeout(syncMobileTextSelection, delay);
|
||||
};
|
||||
disposables.addFromEvent(document, 'selectionchange', () => {
|
||||
scheduleSyncMobileTextSelection(50);
|
||||
});
|
||||
disposables.addFromEvent(host, 'touchend', () => {
|
||||
scheduleTouchSync(100);
|
||||
});
|
||||
disposables.add(() => {
|
||||
if (selectionTimeout) clearTimeout(selectionTimeout);
|
||||
if (touchTimeout) clearTimeout(touchTimeout);
|
||||
});
|
||||
|
||||
// Ensures a stable initial offset before the first viewport event arrives.
|
||||
updateMobilePosition?.();
|
||||
} else {
|
||||
this.shadowRoot!.append(toolbar);
|
||||
}
|
||||
|
||||
// Formatting
|
||||
// Selects text in note.
|
||||
disposables.add(
|
||||
std.selection.find$(TextSelection).subscribe(result => {
|
||||
const range = std.range.value ?? null;
|
||||
const activated = Boolean(
|
||||
context.activated &&
|
||||
range &&
|
||||
result &&
|
||||
!result.isCollapsed() &&
|
||||
result.from.length + (result.to?.length ?? 0)
|
||||
);
|
||||
|
||||
batch(() => {
|
||||
flags.toggle(Flag.Text, activated);
|
||||
|
||||
if (!activated) return;
|
||||
|
||||
this.setReferenceElementWithRange(range);
|
||||
|
||||
sideOptions$.value = null;
|
||||
flavour$.value = 'affine:note';
|
||||
placement$.value = toolbarRegistry.getModulePlacement('affine:note');
|
||||
flags.refresh(Flag.Text);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Formatting
|
||||
// Selects `native` text in database's cell or in table.
|
||||
disposables.addFromEvent(document, 'selectionchange', () => {
|
||||
const range = std.range.value ?? null;
|
||||
let activated = context.activated && Boolean(range && !range.collapsed);
|
||||
|
||||
if (activated) {
|
||||
activated = isNativeTextSelection();
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
// Focues outside: `doc-title`
|
||||
if (
|
||||
flags.check(Flag.Text) &&
|
||||
!std.host.contains(range?.commonAncestorContainer ?? null)
|
||||
) {
|
||||
flags.toggle(Flag.Text, false);
|
||||
}
|
||||
|
||||
flags.toggle(Flag.Native, activated);
|
||||
|
||||
if (!activated) return;
|
||||
|
||||
this.setReferenceElementWithRange(range);
|
||||
|
||||
sideOptions$.value = null;
|
||||
flavour$.value = 'affine:note';
|
||||
placement$.value = toolbarRegistry.getModulePlacement('affine:note');
|
||||
flags.refresh(Flag.Native);
|
||||
});
|
||||
});
|
||||
|
||||
// Selects blocks in note.
|
||||
disposables.add(
|
||||
std.selection.filter$(BlockSelection).subscribe(selections => {
|
||||
const blockIds = selections.map(s => s.blockId);
|
||||
const count = blockIds.length;
|
||||
let flavour = 'affine:note';
|
||||
let activated = context.activated && Boolean(count);
|
||||
|
||||
if (activated) {
|
||||
// Handles a signal block.
|
||||
const block = count === 1 && std.store.getBlock(blockIds[0]);
|
||||
|
||||
// Chencks if block's config exists.
|
||||
if (block) {
|
||||
const modelFlavour = block.model.flavour;
|
||||
const existed =
|
||||
toolbarRegistry.modules.has(modelFlavour) ||
|
||||
toolbarRegistry.modules.has(`custom:${modelFlavour}`);
|
||||
if (existed) {
|
||||
flavour = modelFlavour;
|
||||
} else {
|
||||
activated = matchModels(block.model, [
|
||||
ParagraphBlockModel,
|
||||
ListBlockModel,
|
||||
CodeBlockModel,
|
||||
ImageBlockModel,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
flags.toggle(Flag.Block, activated);
|
||||
|
||||
if (!activated) return;
|
||||
|
||||
this.setReferenceElementWithBlocks(
|
||||
blockIds
|
||||
.map(id => std.view.getBlock(id))
|
||||
.filter(block => block !== null)
|
||||
);
|
||||
|
||||
sideOptions$.value = null;
|
||||
flavour$.value = flavour;
|
||||
placement$.value = toolbarRegistry.getModulePlacement(
|
||||
flavour,
|
||||
flavour === 'affine:note' ? 'top' : 'top-start'
|
||||
);
|
||||
flags.refresh(Flag.Block);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Selects elements in edgeless.
|
||||
// Triggered only when not in editing state.
|
||||
disposables.add(
|
||||
context.gfx.selection.slots.updated.subscribe(selections => {
|
||||
// Should remove selections when clicking on frame navigator
|
||||
if (context.isPageMode) {
|
||||
if (
|
||||
std.host.contains(std.range.value?.commonAncestorContainer ?? null)
|
||||
) {
|
||||
std.range.clear();
|
||||
}
|
||||
context.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const elementIds = selections.flatMap(s =>
|
||||
s.editing || s.inoperable ? [] : s.elements
|
||||
);
|
||||
const count = elementIds.length;
|
||||
const activated = context.activated && Boolean(count);
|
||||
|
||||
this.updateWithSurface(context, activated, elementIds);
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
std.selection.slots.changed.subscribe(selections => {
|
||||
if (!context.activated) return;
|
||||
|
||||
const value = flags.value$.peek();
|
||||
if (flags.contains(Flag.Hovering | Flag.Hiding, value)) return;
|
||||
if (!flags.check(Flag.Text, value)) return;
|
||||
|
||||
const hasTextSelection =
|
||||
selections.filter(s => s.is(TextSelection)).length > 0;
|
||||
if (!hasTextSelection) return;
|
||||
|
||||
const range = std.range.value ?? null;
|
||||
|
||||
this.setReferenceElementWithRange(range);
|
||||
|
||||
// TODO(@fundon): maybe here can be further optimized
|
||||
// 1. Prevents flickering effects.
|
||||
// 2. We cannot use `host.getUpdateComplete()` here
|
||||
// because it would cause excessive DOM queries, leading to UI jamming.
|
||||
nextTick()
|
||||
.then(() => flags.refresh(Flag.Text))
|
||||
.catch(console.error);
|
||||
})
|
||||
);
|
||||
|
||||
// Handles blocks when adding
|
||||
// TODO(@fundon): improve these cases
|
||||
// Waits until the view is created when switching the view mode.
|
||||
// `card view` or `embed view`
|
||||
disposables.add(
|
||||
std.view.viewUpdated.subscribe(record => {
|
||||
const hasAdded = record.type === 'block' && record.method === 'add';
|
||||
if (!hasAdded) return;
|
||||
|
||||
if (flags.isBlock()) {
|
||||
const blockIds = std.selection
|
||||
.filter$(BlockSelection)
|
||||
.peek()
|
||||
.map(s => s.blockId);
|
||||
if (blockIds.includes(record.id)) {
|
||||
batch(() => {
|
||||
this.setReferenceElementWithBlocks(
|
||||
blockIds
|
||||
.map(id => std.view.getBlock(id))
|
||||
.filter(block => block !== null)
|
||||
);
|
||||
flags.refresh(Flag.Block);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.isSurface()) {
|
||||
flags.refresh(Flag.Surface);
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Handles blocks when updating
|
||||
disposables.add(
|
||||
// TODO(@fundon): use rxjs' filter
|
||||
std.store.slots.blockUpdated.subscribe(record => {
|
||||
const hasUpdated = record.type === 'update';
|
||||
if (!hasUpdated) return;
|
||||
|
||||
if (flags.isBlock()) {
|
||||
const blockIds = std.selection
|
||||
.filter$(BlockSelection)
|
||||
.peek()
|
||||
.map(s => s.blockId);
|
||||
if (blockIds.includes(record.id)) {
|
||||
batch(() => {
|
||||
this.setReferenceElementWithBlocks(
|
||||
blockIds
|
||||
.map(id => std.view.getBlock(id))
|
||||
.filter(block => block !== null)
|
||||
);
|
||||
flags.refresh(Flag.Block);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.isSurface()) {
|
||||
const elementIds = context.gfx.selection.selectedIds;
|
||||
this.updateWithSurface(
|
||||
context,
|
||||
context.activated && Boolean(elementIds.length),
|
||||
elementIds
|
||||
);
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Handles elements when updating
|
||||
disposables.add(
|
||||
context.gfx.surface$.subscribe(surface => {
|
||||
if (!surface) return;
|
||||
|
||||
const subscription = surface.elementUpdated.subscribe(() => {
|
||||
if (!flags.isSurface()) return;
|
||||
|
||||
const elementIds = context.gfx.selection.selectedIds;
|
||||
this.updateWithSurface(
|
||||
context,
|
||||
context.activated && Boolean(elementIds.length),
|
||||
elementIds
|
||||
);
|
||||
});
|
||||
|
||||
disposables.add(subscription);
|
||||
})
|
||||
);
|
||||
|
||||
// Handles `drag and drop`
|
||||
const dragStart = () => flags.toggle(Flag.Hiding, true);
|
||||
const dragEnd = () => flags.toggle(Flag.Hiding, false);
|
||||
const eventOptions = { passive: false };
|
||||
this.handleEvent('dragStart', () => {
|
||||
dragStart();
|
||||
document.addEventListener('pointerup', dragEnd, { once: true });
|
||||
});
|
||||
this.handleEvent('nativeDrop', dragEnd);
|
||||
disposables.addFromEvent(host, 'dragenter', dragStart, eventOptions);
|
||||
disposables.addFromEvent(
|
||||
host,
|
||||
'dragleave',
|
||||
throttle(
|
||||
(event: DragEvent) => {
|
||||
const { x, y, target } = event;
|
||||
if (target === this) return;
|
||||
const rect = host.getBoundingClientRect();
|
||||
if (
|
||||
x >= rect.left &&
|
||||
x <= rect.right &&
|
||||
y >= rect.top &&
|
||||
y <= rect.bottom
|
||||
)
|
||||
return;
|
||||
dragEnd();
|
||||
},
|
||||
144,
|
||||
{ trailing: true }
|
||||
),
|
||||
eventOptions
|
||||
);
|
||||
|
||||
// Handles elements when resizing
|
||||
const edgelessSlots = std.getOptional(EdgelessLegacySlotIdentifier);
|
||||
if (edgelessSlots) {
|
||||
disposables.add(edgelessSlots.elementResizeStart.subscribe(dragStart));
|
||||
disposables.add(edgelessSlots.elementResizeEnd.subscribe(dragEnd));
|
||||
}
|
||||
|
||||
// Handles elements when hovering
|
||||
disposables.add(
|
||||
message$.subscribe(data => {
|
||||
if (
|
||||
!context.activated ||
|
||||
flags.contains(Flag.Text | Flag.Native | Flag.Block)
|
||||
) {
|
||||
flags.toggle(Flag.Hovering, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const activated = !!data;
|
||||
|
||||
batch(() => {
|
||||
flags.toggle(Flag.Hovering, activated);
|
||||
|
||||
if (!activated) return;
|
||||
|
||||
const { flavour, setFloating } = data;
|
||||
|
||||
setFloating(toolbar);
|
||||
|
||||
this.setReferenceElementWithHtmlElement(data.element);
|
||||
|
||||
sideOptions$.value = null;
|
||||
flavour$.value = flavour;
|
||||
placement$.value = toolbarRegistry.getModulePlacement(flavour);
|
||||
flags.refresh(Flag.Hovering);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Updates toolbar theme when `app-theme` changing
|
||||
disposables.add(
|
||||
context.theme.app$.subscribe(theme => {
|
||||
toolbar.dataset.appTheme = theme;
|
||||
})
|
||||
);
|
||||
|
||||
// Updates layout when placement changing to `inner`
|
||||
disposables.add(
|
||||
effect(() => {
|
||||
toolbar.dataset.placement = placement$.value;
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
effect(() => {
|
||||
const value = flags.value$.value;
|
||||
|
||||
// Hides toolbar
|
||||
if (Flag.None === value || flags.check(Flag.Hiding, value)) {
|
||||
if ('inline' in toolbar.dataset) delete toolbar.dataset.inline;
|
||||
if (toolbar.dataset.open) delete toolbar.dataset.open;
|
||||
// Closes dropdown menus
|
||||
toolbar
|
||||
.querySelector<EditorMenuButton>('editor-menu-button[data-open]')
|
||||
?.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const flavour = flavour$.value;
|
||||
|
||||
// Shows toolbar
|
||||
// 1. `Flag.Text`: formatting in note
|
||||
// 2. `Flag.Native`: formating in database/table
|
||||
// 3. `Flag.Block`: blocks in note
|
||||
// 4. `Flag.Hovering`: inline links in note/database/table
|
||||
// 5. `Flag.Surface`: elements in edgeless
|
||||
renderToolbar(toolbar, context, flavour);
|
||||
})
|
||||
);
|
||||
|
||||
let abortController = new AbortController();
|
||||
|
||||
disposables.add(
|
||||
effect(() => {
|
||||
if (IS_MOBILE) {
|
||||
const value = flags.value$.value;
|
||||
if (!context.activated) return;
|
||||
if (Flag.None === value || flags.contains(Flag.Hiding, value)) return;
|
||||
updateMobilePosition?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
abortController.abort();
|
||||
}
|
||||
|
||||
const value = flags.value$.value;
|
||||
|
||||
if (!context.activated) return;
|
||||
if (Flag.None === value || flags.contains(Flag.Hiding, value)) return;
|
||||
|
||||
const build = referenceElement$.value;
|
||||
const referenceElement = build?.();
|
||||
if (!referenceElement) return;
|
||||
|
||||
const flavour = flavour$.value;
|
||||
const placement = placement$.value;
|
||||
const sideOptions = sideOptions$.value;
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
abortController = new AbortController();
|
||||
}
|
||||
const signal = abortController.signal;
|
||||
|
||||
const cleanup = autoUpdatePosition(
|
||||
signal,
|
||||
toolbar,
|
||||
referenceElement,
|
||||
flavour,
|
||||
placement,
|
||||
sideOptions
|
||||
);
|
||||
|
||||
signal.addEventListener('abort', cleanup, { once: true });
|
||||
|
||||
return () => {
|
||||
if (signal.aborted) return;
|
||||
abortController.abort();
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
433
blocksuite/affine/widgets/toolbar/src/utils.ts
Normal file
433
blocksuite/affine/widgets/toolbar/src/utils.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import {
|
||||
type EditorMenuButton,
|
||||
type EditorToolbar,
|
||||
renderToolbarSeparator,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
ActionPlacement,
|
||||
type ToolbarAction,
|
||||
type ToolbarActions,
|
||||
type ToolbarContext,
|
||||
type ToolbarPlacement,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { nextTick } from '@blocksuite/global/utils';
|
||||
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
|
||||
import type {
|
||||
AutoUpdateOptions,
|
||||
ComputePositionConfig,
|
||||
ReferenceElement,
|
||||
SideObject,
|
||||
} from '@floating-ui/dom';
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
hide,
|
||||
inline,
|
||||
limitShift,
|
||||
offset,
|
||||
shift,
|
||||
size,
|
||||
} from '@floating-ui/dom';
|
||||
import { html, render } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import groupBy from 'lodash-es/groupBy';
|
||||
import mergeWith from 'lodash-es/mergeWith';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import partition from 'lodash-es/partition';
|
||||
import toPairs from 'lodash-es/toPairs';
|
||||
|
||||
export const sideMap = new Map([
|
||||
// includes frame element
|
||||
['affine:surface:frame', { top: 28 }],
|
||||
// includes group element
|
||||
['affine:surface:group', { top: 20 }],
|
||||
// has only one shape element
|
||||
['affine:surface:shape', { top: 26, bottom: -26 }],
|
||||
]);
|
||||
|
||||
export function autoUpdatePosition(
|
||||
signal: AbortSignal,
|
||||
toolbar: EditorToolbar,
|
||||
referenceElement: ReferenceElement,
|
||||
flavour: string,
|
||||
placement: ToolbarPlacement,
|
||||
sideOptions: Partial<SideObject> | null,
|
||||
options: AutoUpdateOptions = { elementResize: false }
|
||||
) {
|
||||
const isInline = flavour === 'affine:note';
|
||||
const hasSurfaceScope = flavour.includes('surface');
|
||||
const isInner = placement === 'inner';
|
||||
const offsetTop = sideOptions?.top ?? 0;
|
||||
const offsetBottom = sideOptions?.bottom ?? 0;
|
||||
const offsetY = offsetTop + (hasSurfaceScope ? 2 : 0);
|
||||
const config: Partial<ComputePositionConfig> = isInner
|
||||
? {
|
||||
placement: 'top-start',
|
||||
middleware: [
|
||||
offset(({ rects }) => -rects.floating.height),
|
||||
size({
|
||||
apply: ({ elements }) => {
|
||||
const { width } = elements.reference.getBoundingClientRect();
|
||||
elements.floating.style.width = `${width}px`;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
: {
|
||||
placement,
|
||||
middleware: [
|
||||
offset(10 + offsetY),
|
||||
size({
|
||||
apply: ({ elements }) => {
|
||||
elements.floating.style.width = 'fit-content';
|
||||
},
|
||||
}),
|
||||
isInline ? inline() : undefined,
|
||||
shift(state => ({
|
||||
padding: {
|
||||
top: 10,
|
||||
right: 10,
|
||||
bottom: 150,
|
||||
left: 10,
|
||||
},
|
||||
crossAxis: state.placement.includes('bottom'),
|
||||
limiter: limitShift(),
|
||||
})),
|
||||
flip({ padding: 10 }),
|
||||
hide(),
|
||||
],
|
||||
};
|
||||
const update = async () => {
|
||||
await Promise.race([
|
||||
new Promise(resolve => {
|
||||
signal.addEventListener('abort', () => resolve(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
resolve(null);
|
||||
}),
|
||||
isInline ? toolbar.updateComplete.then(nextTick) : toolbar.updateComplete,
|
||||
]);
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
const result = await computePosition(referenceElement, toolbar, config);
|
||||
|
||||
const { x, middlewareData, placement: currentPlacement } = result;
|
||||
const y =
|
||||
result.y -
|
||||
(currentPlacement.includes('top') ? 0 : offsetTop + offsetBottom);
|
||||
|
||||
toolbar.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
||||
|
||||
if (middlewareData.hide) {
|
||||
if (toolbar.dataset.open) {
|
||||
if (middlewareData.hide.referenceHidden) {
|
||||
delete toolbar.dataset.open;
|
||||
// Closes dropdown menus
|
||||
toolbar
|
||||
.querySelector<EditorMenuButton>('editor-menu-button[data-open]')
|
||||
?.hide();
|
||||
}
|
||||
} else {
|
||||
toolbar.dataset.open = 'true';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return autoUpdate(
|
||||
referenceElement,
|
||||
toolbar,
|
||||
() => {
|
||||
update().catch(console.error);
|
||||
},
|
||||
{ animationFrame: hasSurfaceScope, ...options }
|
||||
);
|
||||
}
|
||||
|
||||
export function combine(actions: ToolbarActions, context: ToolbarContext) {
|
||||
const grouped = group(actions);
|
||||
|
||||
const generated = grouped.map(action => {
|
||||
const newAction = {
|
||||
...action,
|
||||
placement: action.placement ?? ActionPlacement.Normal,
|
||||
};
|
||||
|
||||
if ('generate' in action && typeof action.generate === 'function') {
|
||||
// TODO(@fundon): should delete `generate` fn
|
||||
return {
|
||||
...newAction,
|
||||
...action.generate(context),
|
||||
};
|
||||
}
|
||||
|
||||
return newAction;
|
||||
});
|
||||
|
||||
const filtered = generated.filter(action => {
|
||||
if (typeof action.when === 'function') return action.when(context);
|
||||
return action.when ?? true;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function group(actions: ToolbarAction[]): ToolbarAction[] {
|
||||
const grouped = groupBy(actions, a => a.id);
|
||||
|
||||
const paired = toPairs(grouped).map(([_, items]) => {
|
||||
if (items.length === 1) return items[0];
|
||||
const [first, ...others] = items;
|
||||
if (others.length === 1) return merge({ ...first }, others[0]);
|
||||
return others.reduce(merge, { ...first });
|
||||
});
|
||||
|
||||
return paired;
|
||||
}
|
||||
|
||||
const merge = (a: any, b: any) =>
|
||||
mergeWith(a, b, (obj, src) =>
|
||||
Array.isArray(obj) ? group(obj.concat(src)) : src
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders toolbar
|
||||
*
|
||||
* Merges the following configs:
|
||||
* 1. `affine:note`
|
||||
* 2. `custom:affine:note`
|
||||
* 3. `affine:*`
|
||||
* 4. `custom:affine:*`
|
||||
*/
|
||||
export function renderToolbar(
|
||||
toolbar: EditorToolbar,
|
||||
context: ToolbarContext,
|
||||
flavour: string
|
||||
) {
|
||||
const hasSurfaceScope = flavour.includes('surface');
|
||||
const toolbarRegistry = context.toolbarRegistry;
|
||||
|
||||
const actions = [
|
||||
flavour,
|
||||
`custom:${flavour}`,
|
||||
hasSurfaceScope ? ['affine:surface:*', 'custom:affine:surface:*'] : [],
|
||||
'affine:*',
|
||||
'custom:affine:*',
|
||||
]
|
||||
.flat()
|
||||
.map(key => toolbarRegistry.modules.get(key))
|
||||
.filter(module => !!module)
|
||||
.filter(module =>
|
||||
typeof module.config.when === 'function'
|
||||
? module.config.when(context)
|
||||
: (module.config.when ?? true)
|
||||
)
|
||||
.flatMap(module => module.config.actions);
|
||||
|
||||
const combined = combine(actions, context);
|
||||
|
||||
const ordered = orderBy(
|
||||
combined,
|
||||
['placement', 'id', 'score'],
|
||||
['asc', 'asc', 'asc']
|
||||
);
|
||||
|
||||
const [moreActionGroup, primaryActionGroup] = partition(
|
||||
ordered,
|
||||
a => a.placement === ActionPlacement.More
|
||||
);
|
||||
|
||||
// Resets
|
||||
if (primaryActionGroup.length === 0) {
|
||||
context.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const innerToolbar = context.placement$.value === 'inner';
|
||||
|
||||
if (moreActionGroup.length) {
|
||||
const moreMenuItems = renderActions(
|
||||
moreActionGroup,
|
||||
context,
|
||||
renderMenuActionItem
|
||||
);
|
||||
if (moreMenuItems.length) {
|
||||
const key = `${context.getCurrentModel()?.id}`;
|
||||
|
||||
primaryActionGroup.push({
|
||||
id: 'more',
|
||||
content: html`${keyed(
|
||||
`${flavour}:${key}`,
|
||||
html`
|
||||
<editor-menu-button
|
||||
aria-label="More menu"
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="More"
|
||||
.tooltip="${'More'}"
|
||||
.iconContainerPadding=${innerToolbar ? 4 : 2}
|
||||
.iconSize=${innerToolbar ? '16px' : undefined}
|
||||
>
|
||||
${MoreVerticalIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div
|
||||
data-size=${innerToolbar ? '' : 'large'}
|
||||
data-orientation="vertical"
|
||||
>
|
||||
${join(moreMenuItems, renderToolbarSeparator('horizontal'))}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`
|
||||
)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
join(
|
||||
renderActions(primaryActionGroup, context),
|
||||
innerToolbar ? null : renderToolbarSeparator()
|
||||
),
|
||||
toolbar
|
||||
);
|
||||
|
||||
// Avoids shaking
|
||||
if (flavour === 'affine:note' && context.std.range.value) {
|
||||
if (!('inline' in toolbar.dataset)) {
|
||||
toolbar.dataset.inline = '';
|
||||
} else {
|
||||
toolbar.dataset.inline = 'true';
|
||||
}
|
||||
} else {
|
||||
delete toolbar.dataset.inline;
|
||||
}
|
||||
|
||||
if (toolbar.dataset.open) return;
|
||||
toolbar.dataset.open = 'true';
|
||||
}
|
||||
|
||||
function renderActions(
|
||||
actions: ToolbarActions,
|
||||
context: ToolbarContext,
|
||||
render = renderActionItem
|
||||
) {
|
||||
return actions
|
||||
.map(action => {
|
||||
if ('content' in action) {
|
||||
if (typeof action.content === 'function') {
|
||||
return action.content(context);
|
||||
} else {
|
||||
return action.content ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if ('actions' in action && action.actions.length) {
|
||||
const combined = combine(action.actions, context);
|
||||
|
||||
if (!combined.length) return null;
|
||||
|
||||
const ordered = orderBy(combined, ['id', 'score'], ['asc', 'asc']);
|
||||
|
||||
return repeat(
|
||||
ordered,
|
||||
a => a.id,
|
||||
a => {
|
||||
if ('content' in a) {
|
||||
if (typeof a.content === 'function') {
|
||||
return a.content(context);
|
||||
} else {
|
||||
return a.content ?? null;
|
||||
}
|
||||
}
|
||||
return render(a, context);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ('run' in action && action.run) {
|
||||
return render(action, context);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter(action => action !== null);
|
||||
}
|
||||
|
||||
// TODO(@fundon): supports templates
|
||||
function renderActionItem(action: ToolbarAction, context: ToolbarContext) {
|
||||
const innerToolbar = context.placement$.value === 'inner';
|
||||
const ids = action.id.split('.');
|
||||
const id = ids[ids.length - 1];
|
||||
const label = action.label ?? action.tooltip ?? id;
|
||||
const actived =
|
||||
typeof action.active === 'function'
|
||||
? action.active(context)
|
||||
: action.active;
|
||||
const disabled =
|
||||
typeof action.disabled === 'function'
|
||||
? action.disabled(context)
|
||||
: action.disabled;
|
||||
|
||||
return html`
|
||||
<editor-icon-button
|
||||
data-testid=${ifDefined(id)}
|
||||
aria-label=${ifDefined(label)}
|
||||
?active=${actived}
|
||||
?disabled=${disabled}
|
||||
.tooltip=${action.tooltip}
|
||||
.iconContainerPadding=${innerToolbar ? 4 : 2}
|
||||
.iconSize=${innerToolbar ? '16px' : undefined}
|
||||
@click=${() => action.run?.(context)}
|
||||
>
|
||||
${action.icon}
|
||||
${
|
||||
action.showLabel && action.label
|
||||
? html`<span class="label">${action.label}</span>`
|
||||
: null
|
||||
}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMenuActionItem(action: ToolbarAction, context: ToolbarContext) {
|
||||
const innerToolbar = context.placement$.value === 'inner';
|
||||
const ids = action.id.split('.');
|
||||
const id = ids[ids.length - 1];
|
||||
const label = action.label ?? action.tooltip ?? id;
|
||||
const actived =
|
||||
typeof action.active === 'function'
|
||||
? action.active(context)
|
||||
: action.active;
|
||||
const disabled =
|
||||
typeof action.disabled === 'function'
|
||||
? action.disabled(context)
|
||||
: action.disabled;
|
||||
const destructive = action.variant === 'destructive' ? 'delete' : undefined;
|
||||
|
||||
return html`
|
||||
<editor-menu-action
|
||||
data-testid=${ifDefined(id)}
|
||||
aria-label=${ifDefined(label)}
|
||||
class="${ifDefined(destructive)}"
|
||||
?active=${actived}
|
||||
?disabled=${disabled}
|
||||
.tooltip=${ifDefined(action.tooltip)}
|
||||
.iconContainerPadding=${innerToolbar ? 4 : 2}
|
||||
.iconSize=${innerToolbar ? '16px' : undefined}
|
||||
@click=${() => action.run?.(context)}
|
||||
>
|
||||
${action.icon}
|
||||
${action.label ? html`<span class="label">${action.label}</span>` : null}
|
||||
</editor-menu-action>
|
||||
`;
|
||||
}
|
||||
22
blocksuite/affine/widgets/toolbar/src/view.ts
Normal file
22
blocksuite/affine/widgets/toolbar/src/view.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import { toolbarWidget } from '.';
|
||||
import { effects } from './effects';
|
||||
|
||||
export class ToolbarViewExtension extends ViewExtensionProvider {
|
||||
override name = 'affine-toolbar-widget';
|
||||
|
||||
override effect() {
|
||||
super.effect();
|
||||
effects();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
if (this.isMobile(context.scope)) return;
|
||||
context.register(toolbarWidget);
|
||||
}
|
||||
}
|
||||
20
blocksuite/affine/widgets/toolbar/tsconfig.json
Normal file
20
blocksuite/affine/widgets/toolbar/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../blocks/database" },
|
||||
{ "path": "../../blocks/surface" },
|
||||
{ "path": "../../blocks/table" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user