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:
Saul-Mirone
2025-02-20 13:06:39 +00:00
parent 64cc99354e
commit 007bbabce4
43 changed files with 252 additions and 135 deletions
-33
View File
@@ -8,36 +8,10 @@ import {
} from './editors/index.js';
import { CommentInput } from './fragments/comment/comment-input.js';
import {
AFFINE_FRAME_PANEL_BODY,
FramePanelBody,
} from './fragments/frame-panel/body/frame-panel-body.js';
import {
AFFINE_FRAME_CARD,
FrameCard,
} from './fragments/frame-panel/card/frame-card.js';
import {
AFFINE_FRAME_CARD_TITLE,
FrameCardTitle,
} from './fragments/frame-panel/card/frame-card-title.js';
import {
AFFINE_FRAME_TITLE_EDITOR,
FrameCardTitleEditor,
} from './fragments/frame-panel/card/frame-card-title-editor.js';
import {
AFFINE_FRAME_PANEL_HEADER,
FramePanelHeader,
} from './fragments/frame-panel/header/frame-panel-header.js';
import {
AFFINE_FRAMES_SETTING_MENU,
FramesSettingMenu,
} from './fragments/frame-panel/header/frames-setting-menu.js';
import {
AFFINE_FRAME_PANEL,
AFFINE_MOBILE_OUTLINE_MENU,
AFFINE_OUTLINE_PANEL,
AFFINE_OUTLINE_VIEWER,
CommentPanel,
FramePanel,
MobileOutlineMenu,
OutlinePanel,
OutlineViewer,
@@ -74,22 +48,15 @@ export function effects() {
AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU,
OutlineNotePreviewSettingMenu
);
customElements.define(AFFINE_FRAME_PANEL, FramePanel);
customElements.define(AFFINE_OUTLINE_NOTICE, OutlineNotice);
customElements.define('comment-panel', CommentPanel);
customElements.define(AFFINE_OUTLINE_PANEL, OutlinePanel);
customElements.define(AFFINE_OUTLINE_PANEL_HEADER, OutlinePanelHeader);
customElements.define('affine-editor-container', AffineEditorContainer);
customElements.define(AFFINE_OUTLINE_NOTE_CARD, OutlineNoteCard);
customElements.define(AFFINE_FRAME_TITLE_EDITOR, FrameCardTitleEditor);
customElements.define('edgeless-editor', EdgelessEditor);
customElements.define(AFFINE_FRAME_CARD, FrameCard);
customElements.define(AFFINE_OUTLINE_VIEWER, OutlineViewer);
customElements.define(AFFINE_MOBILE_OUTLINE_MENU, MobileOutlineMenu);
customElements.define(AFFINE_FRAME_CARD_TITLE, FrameCardTitle);
customElements.define(AFFINE_OUTLINE_BLOCK_PREVIEW, OutlineBlockPreview);
customElements.define(AFFINE_FRAME_PANEL_BODY, FramePanelBody);
customElements.define(AFFINE_FRAME_PANEL_HEADER, FramePanelHeader);
customElements.define(AFFINE_OUTLINE_PANEL_BODY, OutlinePanelBody);
customElements.define(AFFINE_FRAMES_SETTING_MENU, FramesSettingMenu);
}
@@ -1,439 +0,0 @@
import { type EditorHost, ShadowlessElement } from '@blocksuite/block-std';
import { generateKeyBetweenV2 } from '@blocksuite/block-std/gfx';
import {
DocModeProvider,
EdgelessFrameManager,
EdgelessRootService,
EditPropsStore,
type FrameBlockModel,
} from '@blocksuite/blocks';
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._edgelessRootService?.selection.set({
elements: this._selected,
editing: false,
});
};
private _docDisposables: DisposableGroup | null = null;
private _frameElementHeight = 0;
private _frameItems: FrameListItem[] = [];
private _indicatorTranslateY = 0;
private _lastEdgelessRootId = '';
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._edgelessRootService?.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 _edgelessRootService() {
return this.editorHost.std.getOptional(EdgelessRootService);
}
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);
if (!this._edgelessRootService) {
// 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._edgelessRootService.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._edgelessRootService?.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;
}
}
@@ -1,125 +0,0 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import type { FrameBlockModel, RichText } from '@blocksuite/blocks';
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;
}
}
@@ -1,150 +0,0 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import type { FrameBlockModel } from '@blocksuite/blocks';
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;
}
}
@@ -1,247 +0,0 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { type FrameBlockModel, on, once } from '@blocksuite/blocks';
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;
}
}
@@ -1,88 +0,0 @@
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;
}
}
@@ -1,247 +0,0 @@
import type { EditorHost } from '@blocksuite/block-std';
import {
createButtonPopper,
DocModeProvider,
EdgelessLegacySlotIdentifier,
EdgelessRootService,
EditPropsStore,
type NavigatorMode,
} from '@blocksuite/blocks';
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 readonly _enterPresentationMode = () => {
if (!this._edgelessRootService) {
this.editorHost.std.get(DocModeProvider).setEditorMode('edgeless');
}
setTimeout(() => {
this._edgelessRootService?.gfx.tool.setTool({
type: 'frameNavigator',
mode: this._navigatorMode,
});
}, 100);
};
private _framesSettingMenuPopper: ReturnType<
typeof createButtonPopper
> | null = null;
private _navigatorMode: NavigatorMode = 'fit';
private readonly _setEdgelessDisposables = () => {
if (!this._edgelessRootService) return;
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 get _edgelessRootService() {
return this.editorHost.std.getOptional(EdgelessRootService);
}
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')) {
if (this._edgelessRootService) {
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;
}
}
@@ -1,222 +0,0 @@
import type { EditorHost } from '@blocksuite/block-std';
import {
EdgelessLegacySlotIdentifier,
EdgelessRootService,
EditPropsStore,
} from '@blocksuite/blocks';
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 _edgelessRootService() {
return this.editorHost.std.getOptional(EdgelessRootService);
}
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')) {
if (this._edgelessRootService) {
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;
}
}
@@ -1 +0,0 @@
export * from './frame-panel.js';
@@ -1,154 +0,0 @@
import { type FrameBlockModel, on, once } from '@blocksuite/blocks';
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;
}
@@ -1,3 +1,2 @@
export * from './comment/index.js';
export * from './frame-panel/index.js';
export * from './outline/index.js';