mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08:00
refactor: move frame manager and panel to separate packages (#10324)
### TL;DR Moved frame management functionality from `blocksuite/blocks` to `@blocksuite/affine-block-frame` package. ### What changed? - Relocated `frame-manager.ts` from `blocksuite/blocks` to `@blocksuite/affine-block-frame` - Added new dependencies to block-frame package: `@blocksuite/affine-block-surface` and `yjs` - Updated imports across multiple components to reference frame manager from its new location - Moved utility functions `areSetsEqual` and `isFrameBlock` into frame-manager file - Replaced direct EdgelessRootService references with GfxController in frame panel components ### How to test? 1. Verify frame functionality works in edgeless mode 2. Test frame creation, selection, and manipulation 3. Confirm frame navigation and presentation modes operate correctly 4. Check that frame panel and toolbar interactions remain functional ### Why make this change? This refactoring improves code organization by consolidating frame-related functionality into a dedicated package, making the codebase more modular and easier to maintain. It also reduces dependencies between packages and provides clearer boundaries for frame-related features.
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
@@ -27,6 +28,7 @@
|
||||
"@types/mdast": "^4.0.4",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"yjs": "^13.6.21",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
|
||||
import { Overlay } from '@blocksuite/affine-block-surface';
|
||||
import type { FrameBlockModel } from '@blocksuite/affine-model';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
generateKeyBetweenV2,
|
||||
getTopElements,
|
||||
GfxBlockElementModel,
|
||||
type GfxController,
|
||||
GfxExtension,
|
||||
GfxExtensionIdentifier,
|
||||
type GfxModel,
|
||||
isGfxGroupCompatibleModel,
|
||||
renderableInEdgeless,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import {
|
||||
Bound,
|
||||
deserializeXYWH,
|
||||
DisposableGroup,
|
||||
type IVec,
|
||||
type SerializedXYWH,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { type BlockModel, Text } from '@blocksuite/store';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
const FRAME_PADDING = 40;
|
||||
|
||||
export type NavigatorMode = 'fill' | 'fit';
|
||||
|
||||
export class FrameOverlay extends Overlay {
|
||||
static override overlayName: string = 'frame';
|
||||
|
||||
private _disposable = new DisposableGroup();
|
||||
|
||||
private _frame: FrameBlockModel | null = null;
|
||||
|
||||
private _innerElements = new Set<GfxModel>();
|
||||
|
||||
private readonly _prevXYWH: SerializedXYWH | null = null;
|
||||
|
||||
private get _frameManager() {
|
||||
return this.gfx.std.get(
|
||||
GfxExtensionIdentifier('frame-manager')
|
||||
) as EdgelessFrameManager;
|
||||
}
|
||||
|
||||
constructor(gfx: GfxController) {
|
||||
super(gfx);
|
||||
}
|
||||
|
||||
private _reset() {
|
||||
this._disposable.dispose();
|
||||
this._disposable = new DisposableGroup();
|
||||
|
||||
this._frame = null;
|
||||
this._innerElements.clear();
|
||||
}
|
||||
|
||||
override clear() {
|
||||
if (this._frame === null && this._innerElements.size === 0) return;
|
||||
this._reset();
|
||||
this._renderer?.refresh();
|
||||
}
|
||||
|
||||
highlight(
|
||||
frame: FrameBlockModel,
|
||||
highlightElementsInBound = false,
|
||||
highlightOutline = true
|
||||
) {
|
||||
if (!highlightElementsInBound && !highlightOutline) return;
|
||||
|
||||
let needRefresh = false;
|
||||
|
||||
if (highlightOutline && this._prevXYWH !== frame.xywh) {
|
||||
needRefresh = true;
|
||||
}
|
||||
|
||||
let innerElements = new Set<GfxModel>();
|
||||
if (highlightElementsInBound) {
|
||||
innerElements = new Set(
|
||||
getTopElements(
|
||||
this._frameManager.getElementsInFrameBound(frame)
|
||||
).concat(
|
||||
this._frameManager.getChildElementsInFrame(frame).filter(child => {
|
||||
return frame.intersectsBound(child.elementBound);
|
||||
})
|
||||
)
|
||||
);
|
||||
if (!areSetsEqual(this._innerElements, innerElements)) {
|
||||
needRefresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!needRefresh) return;
|
||||
|
||||
this._reset();
|
||||
if (highlightOutline) this._frame = frame;
|
||||
if (highlightElementsInBound) this._innerElements = innerElements;
|
||||
|
||||
this._disposable.add(
|
||||
frame.deleted.once(() => {
|
||||
this.clear();
|
||||
})
|
||||
);
|
||||
this._renderer?.refresh();
|
||||
}
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D): void {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#1E96EB';
|
||||
ctx.lineWidth = 2 / this.gfx.viewport.zoom;
|
||||
const radius = 2 / this.gfx.viewport.zoom;
|
||||
|
||||
if (this._frame) {
|
||||
const { x, y, w, h } = this._frame.elementBound;
|
||||
ctx.roundRect(x, y, w, h, radius);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
this._innerElements.forEach(element => {
|
||||
const [x, y, w, h] = deserializeXYWH(element.xywh);
|
||||
ctx.translate(x + w / 2, y + h / 2);
|
||||
ctx.rotate(element.rotate);
|
||||
ctx.roundRect(-w / 2, -h / 2, w, h, radius);
|
||||
ctx.translate(-x - w / 2, -y - h / 2);
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class EdgelessFrameManager extends GfxExtension {
|
||||
static override key = 'frame-manager';
|
||||
|
||||
private readonly _disposable = new DisposableGroup();
|
||||
|
||||
/**
|
||||
* 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 Object.values(this.gfx.doc.blocks.value)
|
||||
.map(({ model }) => model)
|
||||
.filter(isFrameBlock)
|
||||
.sort(EdgelessFrameManager.framePresentationComparator);
|
||||
}
|
||||
|
||||
constructor(gfx: GfxController) {
|
||||
super(gfx);
|
||||
this._watchElementAdded();
|
||||
}
|
||||
|
||||
static framePresentationComparator<
|
||||
T extends FrameBlockModel | { index: string; presentationIndex?: string },
|
||||
>(a: T, b: T) {
|
||||
function stringCompare(a: string, b: string) {
|
||||
if (a < b) return -1;
|
||||
if (a > b) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (
|
||||
'presentationIndex$' in a &&
|
||||
'presentationIndex$' in b &&
|
||||
a.presentationIndex$.value &&
|
||||
b.presentationIndex$.value
|
||||
) {
|
||||
return stringCompare(
|
||||
a.presentationIndex$.value,
|
||||
b.presentationIndex$.value
|
||||
);
|
||||
} else if (a.presentationIndex && b.presentationIndex) {
|
||||
return stringCompare(a.presentationIndex, b.presentationIndex);
|
||||
} else if (a.presentationIndex) {
|
||||
return -1;
|
||||
} else if (b.presentationIndex) {
|
||||
return 1;
|
||||
} else {
|
||||
return stringCompare(a.index, b.index);
|
||||
}
|
||||
}
|
||||
|
||||
private _addChildrenToLegacyFrame(frame: FrameBlockModel) {
|
||||
if (frame.childElementIds !== undefined) return;
|
||||
const elements = this.getElementsInFrameBound(frame);
|
||||
const childElements = elements.filter(
|
||||
element => this.getParentFrame(element) === null && element !== frame
|
||||
);
|
||||
|
||||
frame.addChildren(childElements);
|
||||
}
|
||||
|
||||
private _addFrameBlock(bound: Bound) {
|
||||
const surfaceModel = this.gfx.surface as SurfaceBlockModel;
|
||||
const props = this.gfx.std
|
||||
.get(EditPropsStore)
|
||||
.applyLastProps('affine:frame', {
|
||||
title: new Text(new Y.Text(`Frame ${this.frames.length + 1}`)),
|
||||
xywh: bound.serialize(),
|
||||
index: this.gfx.layer.generateIndex(true),
|
||||
presentationIndex: this.generatePresentationIndex(),
|
||||
});
|
||||
|
||||
const id = this.gfx.doc.addBlock('affine:frame', props, surfaceModel);
|
||||
const frameModel = this.gfx.getElementById(id);
|
||||
|
||||
if (!frameModel || !isFrameBlock(frameModel)) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.GfxBlockElementError,
|
||||
'Frame model is not found'
|
||||
);
|
||||
}
|
||||
|
||||
return frameModel;
|
||||
}
|
||||
|
||||
private _watchElementAdded() {
|
||||
if (!this.gfx.surface) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { surface: surfaceModel, doc } = this.gfx;
|
||||
|
||||
this._disposable.add(
|
||||
surfaceModel.elementAdded.on(({ id, local }) => {
|
||||
const element = surfaceModel.getElementById(id);
|
||||
if (element && local) {
|
||||
const frame = this.getFrameFromPoint(element.elementBound.center);
|
||||
|
||||
// if the container created with a frame, skip it.
|
||||
if (
|
||||
isGfxGroupCompatibleModel(element) &&
|
||||
frame &&
|
||||
element.hasChild(frame)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// new element may intended to be added to other group
|
||||
// so we need to wait for the next microtask to check if the element can be added to the frame
|
||||
queueMicrotask(() => {
|
||||
if (!element.group && frame) {
|
||||
this.addElementsToFrame(frame, [element]);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this._disposable.add(
|
||||
doc.slots.blockUpdated.on(payload => {
|
||||
if (
|
||||
payload.type === 'add' &&
|
||||
payload.model instanceof GfxBlockElementModel &&
|
||||
renderableInEdgeless(doc, surfaceModel, payload.model)
|
||||
) {
|
||||
const frame = this.getFrameFromPoint(
|
||||
payload.model.elementBound.center,
|
||||
isFrameBlock(payload.model) ? [payload.model] : []
|
||||
);
|
||||
if (!frame) return;
|
||||
|
||||
if (
|
||||
isFrameBlock(payload.model) &&
|
||||
payload.model.containsBound(frame.elementBound)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.addElementsToFrame(frame, [payload.model]);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset parent of elements to the frame
|
||||
*/
|
||||
addElementsToFrame(frame: FrameBlockModel, elements: GfxModel[]) {
|
||||
if (frame.isLocked()) return;
|
||||
|
||||
if (frame.childElementIds === undefined) {
|
||||
this._addChildrenToLegacyFrame(frame);
|
||||
}
|
||||
|
||||
elements = elements.filter(
|
||||
el => el !== frame && !frame.childElements.includes(el)
|
||||
);
|
||||
|
||||
if (elements.length === 0) return;
|
||||
|
||||
frame.addChildren(elements);
|
||||
}
|
||||
|
||||
createFrameOnBound(bound: Bound) {
|
||||
const frameModel = this._addFrameBlock(bound);
|
||||
|
||||
this.addElementsToFrame(
|
||||
frameModel,
|
||||
getTopElements(this.getElementsInFrameBound(frameModel))
|
||||
);
|
||||
|
||||
this.gfx.doc.captureSync();
|
||||
|
||||
this.gfx.selection.set({
|
||||
elements: [frameModel.id],
|
||||
editing: false,
|
||||
});
|
||||
|
||||
return frameModel;
|
||||
}
|
||||
|
||||
createFrameOnElements(elements: GfxModel[]) {
|
||||
// make sure all elements are in the same level
|
||||
for (const element of elements) {
|
||||
if (element.group !== elements[0].group) return;
|
||||
}
|
||||
|
||||
const parentFrameBound = this.getParentFrame(elements[0])?.elementBound;
|
||||
|
||||
let bound = this.gfx.selection.selectedBound;
|
||||
|
||||
if (parentFrameBound?.contains(bound)) {
|
||||
bound.x -= Math.min(0.5 * (bound.x - parentFrameBound.x), FRAME_PADDING);
|
||||
bound.y -= Math.min(0.5 * (bound.y - parentFrameBound.y), FRAME_PADDING);
|
||||
bound.w += Math.min(
|
||||
0.5 * (parentFrameBound.x + parentFrameBound.w - bound.x - bound.w),
|
||||
FRAME_PADDING
|
||||
);
|
||||
bound.h += Math.min(
|
||||
0.5 * (parentFrameBound.y + parentFrameBound.h - bound.y - bound.h),
|
||||
FRAME_PADDING
|
||||
);
|
||||
} else {
|
||||
bound = bound.expand(FRAME_PADDING);
|
||||
}
|
||||
|
||||
const frameModel = this._addFrameBlock(bound);
|
||||
|
||||
this.addElementsToFrame(frameModel, getTopElements(elements));
|
||||
|
||||
this.gfx.doc.captureSync();
|
||||
|
||||
this.gfx.selection.set({
|
||||
elements: [frameModel.id],
|
||||
editing: false,
|
||||
});
|
||||
|
||||
return frameModel;
|
||||
}
|
||||
|
||||
createFrameOnSelected() {
|
||||
return this.createFrameOnElements(this.gfx.selection.selectedElements);
|
||||
}
|
||||
|
||||
createFrameOnViewportCenter(wh: [number, number]) {
|
||||
const center = this.gfx.viewport.center;
|
||||
const bound = new Bound(
|
||||
center.x - wh[0] / 2,
|
||||
center.y - wh[1] / 2,
|
||||
wh[0],
|
||||
wh[1]
|
||||
);
|
||||
|
||||
this.createFrameOnBound(bound);
|
||||
}
|
||||
|
||||
generatePresentationIndex() {
|
||||
const before =
|
||||
this.frames[this.frames.length - 1]?.presentationIndex ?? null;
|
||||
|
||||
return generateKeyBetweenV2(before, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all elements in the frame, there are three cases:
|
||||
* 1. The frame doesn't have `childElements`, return all elements in the frame bound but not owned by another frame.
|
||||
* 2. Return all child elements of the frame if `childElements` exists.
|
||||
*/
|
||||
getChildElementsInFrame(frame: FrameBlockModel): GfxModel[] {
|
||||
if (frame.childElementIds === undefined) {
|
||||
return this.getElementsInFrameBound(frame).filter(
|
||||
element => this.getParentFrame(element) !== null
|
||||
);
|
||||
}
|
||||
|
||||
const childElements = frame.childIds
|
||||
.map(id => this.gfx.getElementById(id))
|
||||
.filter(element => element !== null);
|
||||
|
||||
return childElements as GfxModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all elements in the frame bound,
|
||||
* whatever the element already has another parent frame or not.
|
||||
*/
|
||||
getElementsInFrameBound(frame: FrameBlockModel, fullyContained = true) {
|
||||
const bound = Bound.deserialize(frame.xywh);
|
||||
const elements: GfxModel[] = this.gfx.grid
|
||||
.search(bound, { strict: fullyContained })
|
||||
.filter(element => element !== frame);
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get most top frame from the point.
|
||||
*/
|
||||
getFrameFromPoint([x, y]: IVec, ignoreFrames: FrameBlockModel[] = []) {
|
||||
for (let i = this.frames.length - 1; i >= 0; i--) {
|
||||
const frame = this.frames[i];
|
||||
if (frame.includesPoint(x, y, {}) && !ignoreFrames.includes(frame)) {
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getParentFrame(element: GfxModel) {
|
||||
const container = element.group;
|
||||
return container && isFrameBlock(container) ? container : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will populate `presentationIndex` for all legacy frames,
|
||||
* and keep the orderer of the legacy frames.
|
||||
*/
|
||||
refreshLegacyFrameOrder() {
|
||||
const frames = this.frames.splice(0, this.frames.length);
|
||||
|
||||
let splitIndex = frames.findIndex(frame => frame.presentationIndex);
|
||||
if (splitIndex === 0) return;
|
||||
|
||||
if (splitIndex === -1) splitIndex = frames.length;
|
||||
|
||||
let afterPreIndex =
|
||||
frames[splitIndex]?.presentationIndex || generateKeyBetweenV2(null, null);
|
||||
|
||||
for (let index = splitIndex - 1; index >= 0; index--) {
|
||||
const preIndex = generateKeyBetweenV2(null, afterPreIndex);
|
||||
frames[index].presentationIndex = preIndex;
|
||||
afterPreIndex = preIndex;
|
||||
}
|
||||
}
|
||||
|
||||
removeAllChildrenFromFrame(frame: FrameBlockModel) {
|
||||
this.gfx.doc.transact(() => {
|
||||
frame.childElementIds = {};
|
||||
});
|
||||
}
|
||||
|
||||
removeFromParentFrame(element: GfxModel) {
|
||||
const parentFrame = this.getParentFrame(element);
|
||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
||||
parentFrame?.removeChild(element);
|
||||
}
|
||||
|
||||
override unmounted(): void {
|
||||
this._disposable.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function areSetsEqual<T>(setA: Set<T>, setB: Set<T>) {
|
||||
if (setA.size !== setB.size) return false;
|
||||
for (const a of setA) if (!setB.has(a)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isFrameBlock(element: unknown): element is FrameBlockModel {
|
||||
return !!element && (element as BlockModel).flavour === 'affine:frame';
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './frame-block.js';
|
||||
export * from './frame-manager.js';
|
||||
export * from './frame-spec.js';
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../block-surface" },
|
||||
{ "path": "../components" },
|
||||
{ "path": "../model" },
|
||||
{ "path": "../shared" },
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-fragment-frame-panel",
|
||||
"description": "Frame panel fragment for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test:unit": "nx vite:test --run --passWithNoTests",
|
||||
"test:unit:coverage": "nx vite:test --run --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-frame": "workspace:*",
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/block-std": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.1",
|
||||
"@blocksuite/inline": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.10",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.11",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./effects": "./src/effects.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.19.0"
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import { EdgelessFrameManager } from '@blocksuite/affine-block-frame';
|
||||
import type { FrameBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
DocModeProvider,
|
||||
EditPropsStore,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { type EditorHost, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import {
|
||||
generateKeyBetweenV2,
|
||||
GfxControllerIdentifier,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
Bound,
|
||||
DisposableGroup,
|
||||
SignalWatcher,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import type { Store } from '@blocksuite/store';
|
||||
import { css, html, nothing, type PropertyValues } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type {
|
||||
DragEvent,
|
||||
FitViewEvent,
|
||||
FrameCard,
|
||||
SelectEvent,
|
||||
} from '../card/frame-card.js';
|
||||
import { startDragging } from '../utils/drag.js';
|
||||
|
||||
const compare = EdgelessFrameManager.framePresentationComparator;
|
||||
|
||||
type FrameListItem = {
|
||||
frame: FrameBlockModel;
|
||||
|
||||
// frame index
|
||||
frameIndex: string;
|
||||
|
||||
// card index
|
||||
cardIndex: number;
|
||||
};
|
||||
|
||||
const styles = css`
|
||||
.frame-list-container {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.no-frame-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.no-frame-placeholder {
|
||||
margin-top: 240px;
|
||||
align-self: center;
|
||||
width: 230px;
|
||||
height: 48px;
|
||||
color: var(--affine-text-secondary-color, #8e8d91);
|
||||
text-align: center;
|
||||
|
||||
/* light/base */
|
||||
font-size: 15px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.insert-indicator {
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background-color: var(--affine-blue-600);
|
||||
position: absolute;
|
||||
contain: layout size;
|
||||
width: 284px;
|
||||
left: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAME_PANEL_BODY = 'affine-frame-panel-body';
|
||||
|
||||
export class FramePanelBody extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = styles;
|
||||
|
||||
private readonly _clearDocDisposables = () => {
|
||||
this._docDisposables?.dispose();
|
||||
this._docDisposables = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* click at blank area to clear selection
|
||||
*/
|
||||
private readonly _clickBlank = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// check if click at frame-card, if not, set this._selected to empty
|
||||
if (
|
||||
(e.target as HTMLElement).closest('frame-card') ||
|
||||
this._selected.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._selected = [];
|
||||
this._gfx.selection.set({
|
||||
elements: this._selected,
|
||||
editing: false,
|
||||
});
|
||||
};
|
||||
|
||||
private _docDisposables: DisposableGroup | null = null;
|
||||
|
||||
private _frameElementHeight = 0;
|
||||
|
||||
private _frameItems: FrameListItem[] = [];
|
||||
|
||||
private _indicatorTranslateY = 0;
|
||||
|
||||
private _lastEdgelessRootId = '';
|
||||
|
||||
private get _gfx() {
|
||||
return this.editorHost.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
private readonly _selectFrame = (e: SelectEvent) => {
|
||||
const { selected, id, multiselect } = e.detail;
|
||||
|
||||
if (!selected) {
|
||||
// de-select frame
|
||||
this._selected = this._selected.filter(frameId => frameId !== id);
|
||||
} else if (multiselect) {
|
||||
this._selected = [...this._selected, id];
|
||||
} else {
|
||||
this._selected = [id];
|
||||
}
|
||||
|
||||
this._gfx.selection.set({
|
||||
elements: this._selected,
|
||||
editing: false,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _updateFrameItems = () => {
|
||||
this._frameItems = this.frames.map((frame, idx) => ({
|
||||
frame,
|
||||
frameIndex: frame.presentationIndex ?? frame.index,
|
||||
cardIndex: idx,
|
||||
}));
|
||||
};
|
||||
|
||||
get frames() {
|
||||
const frames = this.editorHost.doc
|
||||
.getBlocksByFlavour('affine:frame')
|
||||
.map(block => block.model as FrameBlockModel);
|
||||
return frames.sort(compare);
|
||||
}
|
||||
|
||||
get viewportPadding(): [number, number, number, number] {
|
||||
return this.fitPadding
|
||||
? ([0, 0, 0, 0].map((val, idx) =>
|
||||
Number.isFinite(this.fitPadding[idx]) ? this.fitPadding[idx] : val
|
||||
) as [number, number, number, number])
|
||||
: [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
private _drag(e: DragEvent) {
|
||||
if (!this._selected.length) return;
|
||||
|
||||
this._dragging = true;
|
||||
|
||||
const framesMap = this._frameItems.reduce((map, frame) => {
|
||||
map.set(frame.frame.id, {
|
||||
...frame,
|
||||
});
|
||||
return map;
|
||||
}, new Map<string, FrameListItem>());
|
||||
const selected = this._selected.slice();
|
||||
|
||||
const draggedFramesInfo = selected.map(id => {
|
||||
const frame = framesMap.get(id) as FrameListItem;
|
||||
|
||||
return {
|
||||
frame: frame.frame,
|
||||
element: this.renderRoot.querySelector(
|
||||
`[data-frame-id="${frame.frame.id}"]`
|
||||
) as FrameCard,
|
||||
cardIndex: frame.cardIndex,
|
||||
frameIndex: frame.frameIndex,
|
||||
};
|
||||
});
|
||||
const width = draggedFramesInfo[0].element.clientWidth;
|
||||
|
||||
this._frameElementHeight = draggedFramesInfo[0].element.offsetHeight;
|
||||
|
||||
startDragging(draggedFramesInfo, {
|
||||
width,
|
||||
container: this,
|
||||
document: this.ownerDocument,
|
||||
domHost: this.domHost ?? this.ownerDocument,
|
||||
start: {
|
||||
x: e.detail.clientX,
|
||||
y: e.detail.clientY,
|
||||
},
|
||||
framePanelBody: this,
|
||||
frameListContainer: this.frameListContainer,
|
||||
frameElementHeight: this._frameElementHeight,
|
||||
onDragEnd: insertIdx => {
|
||||
this._dragging = false;
|
||||
this.insertIndex = undefined;
|
||||
|
||||
if (insertIdx === undefined || this._frameItems.length <= 1) return;
|
||||
this._reorderFrames(selected, framesMap, insertIdx);
|
||||
},
|
||||
onDragMove: (idx, indicatorTranslateY) => {
|
||||
this.insertIndex = idx;
|
||||
this._indicatorTranslateY = indicatorTranslateY ?? 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _fitToElement(e: FitViewEvent) {
|
||||
const { block } = e.detail;
|
||||
const bound = Bound.deserialize(block.xywh);
|
||||
const docModeProvider = this.editorHost.std.get(DocModeProvider);
|
||||
|
||||
if (docModeProvider.getEditorMode() !== 'edgeless') {
|
||||
// When click frame card in page mode
|
||||
// Should switch to edgeless mode and set viewport to the frame
|
||||
const viewport = {
|
||||
xywh: block.xywh,
|
||||
referenceId: block.id,
|
||||
padding: this.viewportPadding as [number, number, number, number],
|
||||
};
|
||||
|
||||
this.editorHost.std.get(EditPropsStore).setStorage('viewport', viewport);
|
||||
this.editorHost.std.get(DocModeProvider).setEditorMode('edgeless');
|
||||
} else {
|
||||
this._gfx.viewport.setViewportByBound(bound, this.viewportPadding, true);
|
||||
}
|
||||
}
|
||||
|
||||
private _renderEmptyContent() {
|
||||
const emptyContent = html` <div class="no-frame-container">
|
||||
<div class="no-frame-placeholder">
|
||||
Add frames to organize and present your Edgeless
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return emptyContent;
|
||||
}
|
||||
|
||||
private _renderFrameList() {
|
||||
const selectedFrames = new Set(this._selected);
|
||||
const frameCards = html`${repeat(this._frameItems, frameItem => {
|
||||
const { frame, frameIndex, cardIndex } = frameItem;
|
||||
return keyed(
|
||||
frame,
|
||||
html`<affine-frame-card
|
||||
data-frame-id=${frame.id}
|
||||
.frame=${frame}
|
||||
.cardIndex=${cardIndex}
|
||||
.frameIndex=${frameIndex}
|
||||
.status=${selectedFrames.has(frame.id)
|
||||
? this._dragging
|
||||
? 'placeholder'
|
||||
: 'selected'
|
||||
: 'none'}
|
||||
@select=${this._selectFrame}
|
||||
@fitview=${this._fitToElement}
|
||||
@drag=${this._drag}
|
||||
></affine-frame-card>`
|
||||
);
|
||||
})}`;
|
||||
|
||||
const frameList = html` <div class="frame-list-container">
|
||||
${this.insertIndex !== undefined
|
||||
? html`<div
|
||||
class="insert-indicator"
|
||||
style=${`transform: translateY(${this._indicatorTranslateY}px)`}
|
||||
></div>`
|
||||
: nothing}
|
||||
${frameCards}
|
||||
</div>`;
|
||||
return frameList;
|
||||
}
|
||||
|
||||
private _reorderFrames(
|
||||
selected: string[],
|
||||
framesMap: Map<string, FrameListItem>,
|
||||
insertIndex: number
|
||||
) {
|
||||
if (insertIndex >= 0 && insertIndex <= this._frameItems.length) {
|
||||
const frames = Array.from(framesMap.values()).map(
|
||||
frameItem => frameItem.frame
|
||||
);
|
||||
const selectedFrames = selected
|
||||
.map(id => framesMap.get(id) as FrameListItem)
|
||||
.map(frameItem => frameItem.frame)
|
||||
.sort(compare);
|
||||
|
||||
// update selected frames index
|
||||
// make the indexes larger than the frame before and smaller than the frame after
|
||||
let before = frames[insertIndex - 1]?.presentationIndex || null;
|
||||
const after = frames[insertIndex]?.presentationIndex || null;
|
||||
selectedFrames.forEach(frame => {
|
||||
const newIndex = generateKeyBetweenV2(before, after);
|
||||
frame.doc.updateBlock(frame, {
|
||||
presentationIndex: newIndex,
|
||||
});
|
||||
before = newIndex;
|
||||
});
|
||||
|
||||
this.editorHost.doc.captureSync();
|
||||
this._updateFrames();
|
||||
}
|
||||
}
|
||||
|
||||
private _setDocDisposables(doc: Store) {
|
||||
this._clearDocDisposables();
|
||||
this._docDisposables = new DisposableGroup();
|
||||
this._docDisposables.add(
|
||||
doc.slots.blockUpdated.on(({ type, flavour }) => {
|
||||
if (flavour === 'affine:frame' && type !== 'update') {
|
||||
requestAnimationFrame(() => {
|
||||
this._updateFrames();
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _updateFrames() {
|
||||
if (this._dragging) return;
|
||||
|
||||
if (!this.frames.length) {
|
||||
this._selected = [];
|
||||
this._frameItems = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const frameItems: FramePanelBody['_frameItems'] = [];
|
||||
const oldSelectedSet = new Set(this._selected);
|
||||
const newSelected: string[] = [];
|
||||
const frames = this.frames.sort(compare);
|
||||
frames.forEach((frame, idx) => {
|
||||
const frameItem = {
|
||||
frame,
|
||||
frameIndex: frame.presentationIndex ?? frame.index,
|
||||
cardIndex: idx,
|
||||
};
|
||||
|
||||
frameItems.push(frameItem);
|
||||
if (oldSelectedSet.has(frame.id)) {
|
||||
newSelected.push(frame.id);
|
||||
}
|
||||
});
|
||||
|
||||
this._frameItems = frameItems;
|
||||
this._selected = newSelected;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._updateFrameItems();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._clearDocDisposables();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const disposables = this.disposables;
|
||||
disposables.addFromEvent(this, 'click', this._clickBlank);
|
||||
}
|
||||
|
||||
override render() {
|
||||
this._updateFrameItems();
|
||||
return html` ${this._frameItems.length
|
||||
? this._renderFrameList()
|
||||
: this._renderEmptyContent()}`;
|
||||
}
|
||||
|
||||
override updated(_changedProperties: PropertyValues) {
|
||||
if (_changedProperties.has('editorHost') && this.editorHost) {
|
||||
this._setDocDisposables(this.editorHost.doc);
|
||||
// after switch to edgeless mode, should update the selection
|
||||
if (this.editorHost.doc.id === this._lastEdgelessRootId) {
|
||||
this._gfx.selection.set({
|
||||
elements: this._selected,
|
||||
editing: false,
|
||||
});
|
||||
} else {
|
||||
this._selected = this._selected.length ? [] : this._selected;
|
||||
}
|
||||
this._lastEdgelessRootId = this.editorHost.doc.id;
|
||||
}
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _dragging = false;
|
||||
|
||||
// Store the ids of the selected frames
|
||||
@state()
|
||||
private accessor _selected: string[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor domHost!: Document | HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editorHost!: EditorHost;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor fitPadding!: number[];
|
||||
|
||||
@query('.frame-list-container')
|
||||
accessor frameListContainer!: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor insertIndex: number | undefined = undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAME_PANEL_BODY]: FramePanelBody;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { RichText } from '@blocksuite/affine-components/rich-text';
|
||||
import type { FrameBlockModel } from '@blocksuite/affine-model';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
const styles = css`
|
||||
frame-card-title-editor rich-text .nowrap-lines::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAME_TITLE_EDITOR = 'affine-frame-card-title-editor';
|
||||
|
||||
export class FrameCardTitleEditor extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private readonly _isComposing = false;
|
||||
|
||||
get inlineEditor() {
|
||||
return this.richText.inlineEditor;
|
||||
}
|
||||
|
||||
private _unmount() {
|
||||
// dispose in advance to avoid execute `this.remove()` twice
|
||||
this.disposables.dispose();
|
||||
this.remove();
|
||||
this.titleContentElement.style.display = 'block';
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
if (this.inlineEditor === null) return;
|
||||
|
||||
this.titleContentElement.style.display = 'none';
|
||||
|
||||
this.inlineEditor.selectAll();
|
||||
|
||||
this.inlineEditor.slots.renderComplete.on(() => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
const inlineEditorContainer = this.inlineEditor.rootElement;
|
||||
if (!inlineEditorContainer) return;
|
||||
|
||||
this.disposables.addFromEvent(inlineEditorContainer, 'blur', () => {
|
||||
this._unmount();
|
||||
});
|
||||
this.disposables.addFromEvent(inlineEditorContainer, 'click', e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
this.disposables.addFromEvent(inlineEditorContainer, 'dblclick', e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
this.disposables.addFromEvent(inlineEditorContainer, 'keydown', e => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter' && !this._isComposing) {
|
||||
this._unmount();
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this.richText?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const inlineEditorStyle = styleMap({
|
||||
transformOrigin: 'top left',
|
||||
borderRadius: '4px',
|
||||
maxWidth: `${this.maxWidth}px`,
|
||||
maxHeight: '20px',
|
||||
width: 'fit-content',
|
||||
height: '20px',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
lineHeight: '20px',
|
||||
position: 'absolute',
|
||||
left: `${this.left}px`,
|
||||
top: '0px',
|
||||
minWidth: '8px',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
border: '1px solid var(--affine-primary-color)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
boxShadow: '0px 0px 0px 2px rgba(30, 150, 235, 0.30)',
|
||||
zIndex: '1',
|
||||
display: 'block',
|
||||
});
|
||||
return html`<rich-text
|
||||
.yText=${this.frameModel.title.yText}
|
||||
.enableFormat=${false}
|
||||
.enableAutoScrollHorizontally=${true}
|
||||
.enableUndoRedo=${false}
|
||||
.wrapText=${false}
|
||||
style=${inlineEditorStyle}
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor frameModel!: FrameBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor left!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor maxWidth!: number;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText!: RichText;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor titleContentElement!: HTMLElement;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAME_TITLE_EDITOR]: FrameCardTitleEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { FrameBlockModel } from '@blocksuite/affine-model';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { DisposableGroup, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, type PropertyValues } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
import { FrameCardTitleEditor } from './frame-card-title-editor.js';
|
||||
|
||||
const styles = css`
|
||||
.frame-card-title-container {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
gap: 6px;
|
||||
font-size: var(--affine-font-sm);
|
||||
cursor: default;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.frame-card-title-container .card-index {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
background: var(--affine-black);
|
||||
margin-left: 2px;
|
||||
|
||||
color: var(--affine-white);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.frame-card-title-container .card-title {
|
||||
height: 20px;
|
||||
color: var(--affine-text-primary-color);
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAME_CARD_TITLE = 'affine-frame-card-title';
|
||||
|
||||
export class FrameCardTitle extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private readonly _clearTitleDisposables = () => {
|
||||
this._titleDisposables?.dispose();
|
||||
this._titleDisposables = null;
|
||||
};
|
||||
|
||||
private readonly _mountTitleEditor = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const titleEditor = new FrameCardTitleEditor();
|
||||
titleEditor.frameModel = this.frame;
|
||||
titleEditor.titleContentElement = this.titleContentElement;
|
||||
const left = this.titleIndexElement.offsetWidth + 6;
|
||||
titleEditor.left = left;
|
||||
titleEditor.maxWidth = this.titleContainer.offsetWidth - left - 6;
|
||||
this.titleContainer.append(titleEditor);
|
||||
};
|
||||
|
||||
private _titleDisposables: DisposableGroup | null = null;
|
||||
|
||||
private readonly _updateElement = () => {
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private _setFrameDisposables(title: Y.Text) {
|
||||
this._clearTitleDisposables();
|
||||
title.observe(this._updateElement);
|
||||
this._titleDisposables = new DisposableGroup();
|
||||
this._titleDisposables.add({
|
||||
dispose: () => {
|
||||
title.unobserve(this._updateElement);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._clearTitleDisposables();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<div class="frame-card-title-container">
|
||||
<div
|
||||
class="card-index"
|
||||
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||
@dblclick=${(e: MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
${this.cardIndex + 1}
|
||||
</div>
|
||||
<div class="card-title">
|
||||
<span
|
||||
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||
@dblclick=${this._mountTitleEditor}
|
||||
>${this.frame.title}</span
|
||||
>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override updated(_changedProperties: PropertyValues) {
|
||||
if (_changedProperties.has('frame')) {
|
||||
this._setFrameDisposables(this.frame.title.yText);
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor cardIndex!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor frame!: FrameBlockModel;
|
||||
|
||||
@query('.frame-card-title-container')
|
||||
accessor titleContainer!: HTMLElement;
|
||||
|
||||
@query('.frame-card-title-container .card-title')
|
||||
accessor titleContentElement!: HTMLElement;
|
||||
|
||||
@query('.frame-card-title-container .card-index')
|
||||
accessor titleIndexElement!: HTMLElement;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAME_CARD_TITLE]: FrameCardTitle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { FrameBlockModel } from '@blocksuite/affine-model';
|
||||
import { on, once } from '@blocksuite/affine-shared/utils';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export type ReorderEvent = CustomEvent<{
|
||||
currentNumber: number;
|
||||
targetNumber: number;
|
||||
realIndex: number;
|
||||
}>;
|
||||
|
||||
export type SelectEvent = CustomEvent<{
|
||||
id: string;
|
||||
selected: boolean;
|
||||
index: number;
|
||||
multiselect: boolean;
|
||||
}>;
|
||||
|
||||
export type DragEvent = CustomEvent<{
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}>;
|
||||
|
||||
export type FitViewEvent = CustomEvent<{
|
||||
block: FrameBlockModel;
|
||||
}>;
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.frame-card-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 284px;
|
||||
height: 198px;
|
||||
gap: 8px;
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.frame-card-body {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 170px;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
background: var(--affine-background-primary-color);
|
||||
box-shadow: 0px 0px 12px 0px rgba(66, 65, 73, 0.18);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.frame-card-container.selected .frame-card-body {
|
||||
border: 2px solid var(--light-brand-color, #1e96eb);
|
||||
}
|
||||
|
||||
.frame-card-container.dragging {
|
||||
pointer-events: none;
|
||||
transform-origin: 16px 8px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: calc(var(--affine-z-index-popover, 0) + 3);
|
||||
}
|
||||
|
||||
.frame-card-container.dragging frame-card-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.frame-card-container.dragging .dragging-card-number {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform: translate(-30%, 30%);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--affine-black);
|
||||
color: var(--affine-white);
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.frame-card-container.placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAME_CARD = 'affine-frame-card';
|
||||
|
||||
export class FrameCard extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private _dispatchDragEvent(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const { clientX: startX, clientY: startY } = e;
|
||||
const disposeDragStart = on(this.ownerDocument, 'mousemove', e => {
|
||||
if (
|
||||
Math.abs(startX - e.clientX) < 5 &&
|
||||
Math.abs(startY - e.clientY) < 5
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this.status !== 'selected') {
|
||||
this._dispatchSelectEvent(e);
|
||||
}
|
||||
|
||||
const event = new CustomEvent('drag', {
|
||||
detail: {
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
pageX: e.pageX,
|
||||
pageY: e.pageY,
|
||||
},
|
||||
});
|
||||
|
||||
this.dispatchEvent(event);
|
||||
disposeDragStart();
|
||||
});
|
||||
|
||||
once(this.ownerDocument, 'mouseup', () => {
|
||||
disposeDragStart();
|
||||
});
|
||||
}
|
||||
|
||||
private _dispatchFitViewEvent(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
|
||||
const event = new CustomEvent('fitview', {
|
||||
detail: {
|
||||
block: this.frame,
|
||||
},
|
||||
});
|
||||
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
private _dispatchSelectEvent(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
const event = new CustomEvent('select', {
|
||||
detail: {
|
||||
id: this.frame.id,
|
||||
selected: this.status !== 'selected',
|
||||
index: this.cardIndex,
|
||||
multiselect: e.shiftKey,
|
||||
},
|
||||
}) as SelectEvent;
|
||||
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
private _DraggingCardNumber() {
|
||||
if (this.draggingCardNumber === undefined) return nothing;
|
||||
|
||||
return html`<div class="dragging-card-number">
|
||||
${this.draggingCardNumber}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { pos, stackOrder, width } = this;
|
||||
const containerStyle =
|
||||
this.status === 'dragging'
|
||||
? styleMap({
|
||||
transform: `${
|
||||
stackOrder === 0
|
||||
? `translate(${pos.x - 16}px, ${pos.y - 8}px)`
|
||||
: `translate(${pos.x - 10}px, ${pos.y - 16}px) scale(0.96)`
|
||||
}`,
|
||||
width: width ? `${width}px` : undefined,
|
||||
})
|
||||
: {};
|
||||
|
||||
return html`<div
|
||||
class="frame-card-container ${this.status ?? ''}"
|
||||
style=${containerStyle}
|
||||
>
|
||||
${this.status === 'dragging'
|
||||
? nothing
|
||||
: html`<affine-frame-card-title
|
||||
.cardIndex=${this.cardIndex}
|
||||
.frame=${this.frame}
|
||||
></affine-frame-card-title>`}
|
||||
<div
|
||||
class="frame-card-body"
|
||||
@click=${this._dispatchSelectEvent}
|
||||
@dblclick=${this._dispatchFitViewEvent}
|
||||
@mousedown=${this._dispatchDragEvent}
|
||||
>
|
||||
${this.status === 'dragging' && stackOrder !== 0
|
||||
? nothing
|
||||
: html`<frame-preview .frame=${this.frame}></frame-preview>`}
|
||||
${this._DraggingCardNumber()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor cardIndex!: number;
|
||||
|
||||
@query('.frame-card-container')
|
||||
accessor containerElement!: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor draggingCardNumber: number | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor frame!: FrameBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor frameIndex!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor pos!: { x: number; y: number };
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor stackOrder!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor status: 'selected' | 'dragging' | 'placeholder' | 'none' = 'none';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor width: number | undefined = undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAME_CARD]: FrameCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
AFFINE_FRAME_PANEL_BODY,
|
||||
FramePanelBody,
|
||||
} from './body/frame-panel-body';
|
||||
import { AFFINE_FRAME_CARD, FrameCard } from './card/frame-card';
|
||||
import {
|
||||
AFFINE_FRAME_CARD_TITLE,
|
||||
FrameCardTitle,
|
||||
} from './card/frame-card-title';
|
||||
import {
|
||||
AFFINE_FRAME_TITLE_EDITOR,
|
||||
FrameCardTitleEditor,
|
||||
} from './card/frame-card-title-editor';
|
||||
import { AFFINE_FRAME_PANEL, FramePanel } from './frame-panel';
|
||||
import {
|
||||
AFFINE_FRAME_PANEL_HEADER,
|
||||
FramePanelHeader,
|
||||
} from './header/frame-panel-header';
|
||||
import {
|
||||
AFFINE_FRAMES_SETTING_MENU,
|
||||
FramesSettingMenu,
|
||||
} from './header/frames-setting-menu';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(AFFINE_FRAME_PANEL, FramePanel);
|
||||
customElements.define(AFFINE_FRAME_TITLE_EDITOR, FrameCardTitleEditor);
|
||||
customElements.define(AFFINE_FRAME_CARD, FrameCard);
|
||||
customElements.define(AFFINE_FRAME_CARD_TITLE, FrameCardTitle);
|
||||
customElements.define(AFFINE_FRAME_PANEL_BODY, FramePanelBody);
|
||||
customElements.define(AFFINE_FRAME_PANEL_HEADER, FramePanelHeader);
|
||||
customElements.define(AFFINE_FRAMES_SETTING_MENU, FramesSettingMenu);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { type EditorHost, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
const styles = css`
|
||||
frame-panel {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.frame-panel-container {
|
||||
background-color: var(--affine-background-primary-color);
|
||||
box-sizing: border-box;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
height: 100%;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.frame-panel-body {
|
||||
padding-top: 12px;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin; /* For Firefox */
|
||||
scrollbar-color: transparent transparent; /* For Firefox */
|
||||
}
|
||||
|
||||
.frame-panel-body::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.frame-panel-body::-webkit-scrollbar-thumb {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.frame-panel-body:hover::-webkit-scrollbar-thumb {
|
||||
background-color: var(--affine-black-30);
|
||||
}
|
||||
|
||||
.frame-panel-body::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.frame-panel-body::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAME_PANEL = 'affine-frame-panel';
|
||||
|
||||
export class FramePanel extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = styles;
|
||||
|
||||
override render() {
|
||||
return html`<div class="frame-panel-container">
|
||||
<affine-frame-panel-header
|
||||
.editorHost=${this.host}
|
||||
></affine-frame-panel-header>
|
||||
<affine-frame-panel-body
|
||||
class="frame-panel-body"
|
||||
.editorHost=${this.host}
|
||||
.fitPadding=${this.fitPadding}
|
||||
></affine-frame-panel-body>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor fitPadding: number[] = [50, 380, 50, 50];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor host!: EditorHost;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAME_PANEL]: FramePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { NavigatorMode } from '@blocksuite/affine-block-frame';
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
DocModeProvider,
|
||||
EditPropsStore,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { createButtonPopper } from '@blocksuite/affine-shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import { DisposableGroup, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { PresentationIcon, SettingsIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html, LitElement, type PropertyValues } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.frame-panel-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.all-frames-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100px;
|
||||
height: 24px;
|
||||
margin: 8px 4px;
|
||||
}
|
||||
|
||||
.all-frames-setting-button svg {
|
||||
color: var(--affine-icon-secondary);
|
||||
}
|
||||
|
||||
.all-frames-setting-button:hover svg,
|
||||
.all-frames-setting-button.active svg {
|
||||
color: var(--affine-icon-color);
|
||||
}
|
||||
|
||||
.all-frames-setting-label {
|
||||
width: 68px;
|
||||
height: 22px;
|
||||
font-size: var(--affine-font-sm);
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
color: var(--light-text-color-text-secondary-color, #8e8d91);
|
||||
}
|
||||
|
||||
.frames-setting-container {
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.frames-setting-container[data-show] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.presentation-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
width: 117px;
|
||||
height: 28px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
margin: 4px 0;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
background: var(--affine-white);
|
||||
}
|
||||
|
||||
.presentation-button:hover {
|
||||
background: var(--affine-hover-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.presentation-button svg {
|
||||
fill: var(--affine-icon-color);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.presentation-button-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAME_PANEL_HEADER = 'affine-frame-panel-header';
|
||||
|
||||
export class FramePanelHeader extends WithDisposable(LitElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private readonly _clearEdgelessDisposables = () => {
|
||||
this._edgelessDisposables?.dispose();
|
||||
this._edgelessDisposables = null;
|
||||
};
|
||||
|
||||
private _edgelessDisposables: DisposableGroup | null = null;
|
||||
|
||||
private get _gfx() {
|
||||
return this.editorHost.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
private readonly _enterPresentationMode = () => {
|
||||
const docModeProvider = this.editorHost.std.get(DocModeProvider);
|
||||
if (docModeProvider.getEditorMode() !== 'edgeless') {
|
||||
this.editorHost.std.get(DocModeProvider).setEditorMode('edgeless');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this._gfx.tool.setTool({
|
||||
type: 'frameNavigator',
|
||||
mode: this._navigatorMode,
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
private _framesSettingMenuPopper: ReturnType<
|
||||
typeof createButtonPopper
|
||||
> | null = null;
|
||||
|
||||
private _navigatorMode: NavigatorMode = 'fit';
|
||||
|
||||
private readonly _setEdgelessDisposables = () => {
|
||||
const slots = this.editorHost.std.get(EdgelessLegacySlotIdentifier);
|
||||
|
||||
this._clearEdgelessDisposables();
|
||||
this._edgelessDisposables = new DisposableGroup();
|
||||
this._edgelessDisposables.add(
|
||||
slots.navigatorSettingUpdated.on(({ fillScreen }) => {
|
||||
this._navigatorMode = fillScreen ? 'fill' : 'fit';
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
private _tryLoadNavigatorStateLocalRecord() {
|
||||
this._navigatorMode = this.editorHost.std
|
||||
.get(EditPropsStore)
|
||||
.getStorage('presentFillScreen')
|
||||
? 'fill'
|
||||
: 'fit';
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._tryLoadNavigatorStateLocalRecord();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._edgelessDisposables) {
|
||||
this._clearEdgelessDisposables();
|
||||
}
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const disposables = this.disposables;
|
||||
|
||||
this._framesSettingMenuPopper = createButtonPopper(
|
||||
this._frameSettingButton,
|
||||
this._frameSettingMenu,
|
||||
({ display }) => {
|
||||
this._settingPopperShow = display === 'show';
|
||||
},
|
||||
{
|
||||
mainAxis: 14,
|
||||
crossAxis: -100,
|
||||
}
|
||||
);
|
||||
disposables.add(this._framesSettingMenuPopper);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<div class="frame-panel-header">
|
||||
<div class="all-frames-setting">
|
||||
<span class="all-frames-setting-label">All frames</span>
|
||||
<edgeless-tool-icon-button
|
||||
class="all-frames-setting-button ${this._settingPopperShow
|
||||
? 'active'
|
||||
: ''}"
|
||||
.tooltip=${this._settingPopperShow ? '' : 'All Frames Settings'}
|
||||
.tipPosition=${'top'}
|
||||
.active=${this._settingPopperShow}
|
||||
.activeMode=${'background'}
|
||||
@click=${() => this._framesSettingMenuPopper?.toggle()}
|
||||
>
|
||||
${SettingsIcon({ width: '20px', height: '20px' })}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
<div class="frames-setting-container">
|
||||
<affine-frames-setting-menu
|
||||
.editorHost=${this.editorHost}
|
||||
></affine-frames-setting-menu>
|
||||
</div>
|
||||
<div class="presentation-button" @click=${this._enterPresentationMode}>
|
||||
${PresentationIcon({ width: '16px', height: '16px' })}<span
|
||||
class="presentation-button-label"
|
||||
>Presentation</span
|
||||
>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override updated(_changedProperties: PropertyValues) {
|
||||
if (_changedProperties.has('editorHost')) {
|
||||
const docModeProvider = this.editorHost.std.get(DocModeProvider);
|
||||
if (docModeProvider.getEditorMode() === 'edgeless') {
|
||||
this._setEdgelessDisposables();
|
||||
} else {
|
||||
this._clearEdgelessDisposables();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@query('.all-frames-setting-button')
|
||||
private accessor _frameSettingButton!: HTMLDivElement;
|
||||
|
||||
@query('.frames-setting-container')
|
||||
private accessor _frameSettingMenu!: HTMLDivElement;
|
||||
|
||||
@state()
|
||||
private accessor _settingPopperShow = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editorHost!: EditorHost;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAME_PANEL_HEADER]: FramePanelHeader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
DocModeProvider,
|
||||
EditPropsStore,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement, type PropertyValues } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.frames-setting-menu-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.frames-setting-menu-item {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
padding: 4px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.frames-setting-menu-item .setting-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.frames-setting-menu-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
box-sizing: border-box;
|
||||
background: var(--affine-border-color);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.frames-setting-menu-item.action {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.frames-setting-menu-item .action-label {
|
||||
width: 138px;
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
.frames-setting-menu-item .toggle-button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
menu-divider {
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAMES_SETTING_MENU = 'affine-frames-setting-menu';
|
||||
|
||||
export class FramesSettingMenu extends WithDisposable(LitElement) {
|
||||
static override styles = styles;
|
||||
|
||||
get slots() {
|
||||
return this.editorHost.std.get(EdgelessLegacySlotIdentifier);
|
||||
}
|
||||
|
||||
private readonly _onBlackBackgroundChange = (checked: boolean) => {
|
||||
this.blackBackground = checked;
|
||||
this.slots.navigatorSettingUpdated.emit({
|
||||
blackBackground: this.blackBackground,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _onFillScreenChange = (checked: boolean) => {
|
||||
this.fillScreen = checked;
|
||||
this.slots.navigatorSettingUpdated.emit({
|
||||
fillScreen: this.fillScreen,
|
||||
});
|
||||
this._editPropsStore.setStorage('presentFillScreen', this.fillScreen);
|
||||
};
|
||||
|
||||
private readonly _onHideToolBarChange = (checked: boolean) => {
|
||||
this.hideToolbar = checked;
|
||||
this.slots.navigatorSettingUpdated.emit({
|
||||
hideToolbar: this.hideToolbar,
|
||||
});
|
||||
this._editPropsStore.setStorage('presentHideToolbar', this.hideToolbar);
|
||||
};
|
||||
|
||||
private get _editPropsStore() {
|
||||
return this.editorHost.std.get(EditPropsStore);
|
||||
}
|
||||
|
||||
private _tryRestoreSettings() {
|
||||
const blackBackground = this._editPropsStore.getStorage(
|
||||
'presentBlackBackground'
|
||||
);
|
||||
|
||||
this.blackBackground = blackBackground ?? true;
|
||||
this.fillScreen =
|
||||
this._editPropsStore.getStorage('presentFillScreen') ?? false;
|
||||
this.hideToolbar =
|
||||
this._editPropsStore.getStorage('presentHideToolbar') ?? false;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._tryRestoreSettings();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<div
|
||||
class="frames-setting-menu-container"
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div class="frames-setting-menu-item">
|
||||
<div class="setting-label">Preview Settings</div>
|
||||
</div>
|
||||
<div class="frames-setting-menu-item action">
|
||||
<div class="action-label">Fill Screen</div>
|
||||
<div class="toggle-button">
|
||||
<toggle-switch
|
||||
.on=${this.fillScreen}
|
||||
.onChange=${this._onFillScreenChange}
|
||||
></toggle-switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<menu-divider></menu-divider>
|
||||
|
||||
<div class="frames-setting-menu-item">
|
||||
<div class="setting-label">Playback Settings</div>
|
||||
</div>
|
||||
<div class="frames-setting-menu-item action">
|
||||
<div class="action-label">Dark background</div>
|
||||
<div class="toggle-button">
|
||||
<toggle-switch
|
||||
.on=${this.blackBackground}
|
||||
.onChange=${this._onBlackBackgroundChange}
|
||||
></toggle-switch>
|
||||
</div>
|
||||
</div>
|
||||
<div class="frames-setting-menu-item action">
|
||||
<div class="action-label">Hide toolbar</div>
|
||||
<div class="toggle-button">
|
||||
<toggle-switch
|
||||
.on=${this.hideToolbar}
|
||||
.onChange=${this._onHideToolBarChange}
|
||||
></toggle-switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override updated(_changedProperties: PropertyValues) {
|
||||
if (_changedProperties.has('editorHost')) {
|
||||
const docModeProvider = this.editorHost.std.get(DocModeProvider);
|
||||
if (docModeProvider.getEditorMode() === 'edgeless') {
|
||||
this.disposables.add(
|
||||
this.slots.navigatorSettingUpdated.on(
|
||||
({ blackBackground, hideToolbar }) => {
|
||||
if (
|
||||
blackBackground !== undefined &&
|
||||
blackBackground !== this.blackBackground
|
||||
) {
|
||||
this.blackBackground = blackBackground;
|
||||
}
|
||||
|
||||
if (
|
||||
hideToolbar !== undefined &&
|
||||
hideToolbar !== this.hideToolbar
|
||||
) {
|
||||
this.hideToolbar = hideToolbar;
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor blackBackground = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editorHost!: EditorHost;
|
||||
|
||||
@state()
|
||||
accessor fillScreen = false;
|
||||
|
||||
@state()
|
||||
accessor hideToolbar = false;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAMES_SETTING_MENU]: FramesSettingMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './frame-panel';
|
||||
export * from './tool';
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { NavigatorMode } from '@blocksuite/affine-block-frame';
|
||||
import { BaseTool } from '@blocksuite/block-std/gfx';
|
||||
|
||||
type PresentToolOption = {
|
||||
mode?: NavigatorMode;
|
||||
};
|
||||
|
||||
export class PresentTool extends BaseTool<PresentToolOption> {
|
||||
static override toolName: string = 'frameNavigator';
|
||||
}
|
||||
|
||||
declare module '@blocksuite/block-std/gfx' {
|
||||
interface GfxToolsMap {
|
||||
frameNavigator: PresentTool;
|
||||
}
|
||||
|
||||
interface GfxToolsOption {
|
||||
frameNavigator: PresentToolOption;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { FrameBlockModel } from '@blocksuite/affine-model';
|
||||
import { on, once } from '@blocksuite/affine-shared/utils';
|
||||
|
||||
import type { FramePanelBody } from '../body/frame-panel-body.js';
|
||||
import { FrameCard } from '../card/frame-card.js';
|
||||
|
||||
/**
|
||||
* start drag frame cards
|
||||
* @param frames frames to drag
|
||||
*/
|
||||
export function startDragging(
|
||||
frames: {
|
||||
frame: FrameBlockModel;
|
||||
element: FrameCard;
|
||||
cardIndex: number;
|
||||
frameIndex: string;
|
||||
}[],
|
||||
options: {
|
||||
width: number;
|
||||
onDragEnd?: (insertIndex?: number) => void;
|
||||
onDragMove?: (insertIdx?: number, indicatorTranslateY?: number) => void;
|
||||
framePanelBody: HTMLElement;
|
||||
frameListContainer: HTMLElement;
|
||||
frameElementHeight: number;
|
||||
document: Document;
|
||||
domHost: Document | HTMLElement;
|
||||
container: FramePanelBody;
|
||||
start: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
) {
|
||||
const {
|
||||
document,
|
||||
domHost,
|
||||
container,
|
||||
onDragMove,
|
||||
onDragEnd,
|
||||
frameElementHeight,
|
||||
framePanelBody,
|
||||
frameListContainer,
|
||||
start,
|
||||
} = options;
|
||||
const cardElements = frames
|
||||
.slice(frames.length - 2, frames.length)
|
||||
.map((frame, idx, arr) => {
|
||||
const el = new FrameCard();
|
||||
|
||||
el.frame = frame.frame;
|
||||
|
||||
el.cardIndex = frame.cardIndex;
|
||||
el.frameIndex = frame.frameIndex;
|
||||
el.status = 'dragging';
|
||||
el.stackOrder = arr.length - 1 - idx;
|
||||
el.pos = start;
|
||||
el.width = options.width;
|
||||
if (frames.length > 1 && el.stackOrder === 0)
|
||||
el.draggingCardNumber = frames.length;
|
||||
|
||||
return el;
|
||||
});
|
||||
const maskElement = createMaskElement(document);
|
||||
const listContainerRect = framePanelBody.getBoundingClientRect();
|
||||
const children = Array.from(frameListContainer.children) as FrameCard[];
|
||||
const computedStyle = getComputedStyle(frameListContainer);
|
||||
const frameListContainerGap =
|
||||
parseInt(computedStyle.getPropertyValue('gap')) ?? 16;
|
||||
let idx: undefined | number;
|
||||
let indicatorTranslateY: undefined | number;
|
||||
|
||||
container.renderRoot.append(maskElement);
|
||||
container.renderRoot.append(...cardElements);
|
||||
|
||||
const insideListContainer = (e: MouseEvent) => {
|
||||
return (
|
||||
e.clientX >= listContainerRect.left &&
|
||||
e.clientX <= listContainerRect.right &&
|
||||
e.clientY >= listContainerRect.top &&
|
||||
e.clientY <= listContainerRect.bottom
|
||||
);
|
||||
};
|
||||
|
||||
const disposeMove = on(container, 'mousemove', e => {
|
||||
cardElements.forEach(el => {
|
||||
el.pos = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
};
|
||||
});
|
||||
|
||||
if (!insideListContainer(e)) {
|
||||
idx = undefined;
|
||||
onDragMove?.(idx, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
idx = 0;
|
||||
for (const card of children) {
|
||||
if (!card.frame) break;
|
||||
|
||||
const topBoundary =
|
||||
listContainerRect.top +
|
||||
card.offsetTop -
|
||||
framePanelBody.scrollTop -
|
||||
frameListContainerGap / 2;
|
||||
const midBoundary = topBoundary + card.offsetHeight / 2;
|
||||
const bottomBoundary =
|
||||
topBoundary + card.offsetHeight + frameListContainerGap;
|
||||
|
||||
if (e.clientY >= topBoundary && e.clientY <= bottomBoundary) {
|
||||
idx = e.clientY > midBoundary ? idx + 1 : idx;
|
||||
|
||||
indicatorTranslateY =
|
||||
idx * (frameElementHeight + frameListContainerGap) -
|
||||
frameListContainerGap / 2;
|
||||
|
||||
onDragMove?.(idx, indicatorTranslateY);
|
||||
return;
|
||||
}
|
||||
|
||||
++idx;
|
||||
}
|
||||
|
||||
onDragMove?.(idx);
|
||||
});
|
||||
|
||||
let ended = false;
|
||||
const dragEnd = () => {
|
||||
if (ended) return;
|
||||
|
||||
ended = true;
|
||||
cardElements.forEach(child => child.remove());
|
||||
maskElement.remove();
|
||||
|
||||
disposeMove();
|
||||
onDragEnd?.(idx);
|
||||
};
|
||||
|
||||
once(domHost as Document, 'mouseup', dragEnd);
|
||||
}
|
||||
|
||||
function createMaskElement(doc: Document) {
|
||||
const mask = doc.createElement('div');
|
||||
|
||||
mask.style.height = '100vh';
|
||||
mask.style.width = '100vw';
|
||||
mask.style.position = 'fixed';
|
||||
mask.style.left = '0';
|
||||
mask.style.top = '0';
|
||||
mask.style.zIndex = 'calc(var(--affine-z-index-popover, 0) + 3)';
|
||||
mask.style.cursor = 'grabbing';
|
||||
|
||||
return mask;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../block-frame" },
|
||||
{ "path": "../block-surface" },
|
||||
{ "path": "../components" },
|
||||
{ "path": "../model" },
|
||||
{ "path": "../shared" },
|
||||
{ "path": "../../framework/block-std" },
|
||||
{ "path": "../../framework/global" },
|
||||
{ "path": "../../framework/inline" },
|
||||
{ "path": "../../framework/store" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user