snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
{
"name": "@blocksuite/affine-fragment-adapter-panel",
"description": "Adapter panel fragment for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"lit": "^3.2.0"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,177 @@
import type { Store, TransformerMiddleware } from '@blocksuite/affine/store';
import {
type HtmlAdapter,
HtmlAdapterFactoryIdentifier,
type MarkdownAdapter,
MarkdownAdapterFactoryIdentifier,
type PlainTextAdapter,
PlainTextAdapterFactoryIdentifier,
} from '@blocksuite/affine-shared/adapters';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { provide } from '@lit/context';
import { effect, signal } from '@preact/signals-core';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, type PropertyValues, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import {
type AdapterPanelContext,
adapterPanelContext,
ADAPTERS,
} from './config';
export const AFFINE_ADAPTER_PANEL = 'affine-adapter-panel';
export class AdapterPanel extends SignalWatcher(WithDisposable(LitElement)) {
static override styles = css`
:host {
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.adapters-container {
width: 100%;
height: 100%;
background-color: var(--affine-background-primary-color);
box-sizing: border-box;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
`;
get activeAdapter() {
return this._context.activeAdapter$.value;
}
private _createJob() {
return this.store.getTransformer(this.transformerMiddlewares);
}
private _getDocSnapshot() {
const job = this._createJob();
const result = job.docToSnapshot(this.store);
return result;
}
private async _getHtmlContent() {
try {
const job = this._createJob();
const htmlAdapterFactory = this.store.get(HtmlAdapterFactoryIdentifier);
const htmlAdapter = htmlAdapterFactory.get(job) as HtmlAdapter;
const result = await htmlAdapter.fromDoc(this.store);
return result?.file;
} catch (error) {
console.error('Failed to get html content', error);
return '';
}
}
private async _getMarkdownContent() {
try {
const job = this._createJob();
const markdownAdapterFactory = this.store.get(
MarkdownAdapterFactoryIdentifier
);
const markdownAdapter = markdownAdapterFactory.get(
job
) as MarkdownAdapter;
const result = await markdownAdapter.fromDoc(this.store);
return result?.file;
} catch (error) {
console.error('Failed to get markdown content', error);
return '';
}
}
private async _getPlainTextContent() {
try {
const job = this._createJob();
const plainTextAdapterFactory = this.store.get(
PlainTextAdapterFactoryIdentifier
);
const plainTextAdapter = plainTextAdapterFactory.get(
job
) as PlainTextAdapter;
const result = await plainTextAdapter.fromDoc(this.store);
return result?.file;
} catch (error) {
console.error('Failed to get plain text content', error);
return '';
}
}
private readonly _updateActiveContent = async () => {
const activeId = this.activeAdapter.id;
switch (activeId) {
case 'markdown':
this._context.markdownContent$.value =
(await this._getMarkdownContent()) || '';
break;
case 'html':
this._context.htmlContent$.value = (await this._getHtmlContent()) || '';
break;
case 'plaintext':
this._context.plainTextContent$.value =
(await this._getPlainTextContent()) || '';
break;
case 'snapshot':
this._context.docSnapshot$.value = this._getDocSnapshot() || null;
break;
}
};
override connectedCallback() {
super.connectedCallback();
this._context = {
activeAdapter$: signal(ADAPTERS[0]),
isHtmlPreview$: signal(false),
docSnapshot$: signal(null),
htmlContent$: signal(''),
markdownContent$: signal(''),
plainTextContent$: signal(''),
};
}
override willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('store')) {
this._updateActiveContent().catch(console.error);
}
}
override firstUpdated() {
this.disposables.add(
effect(() => {
if (this.activeAdapter) {
this._updateActiveContent().catch(console.error);
}
})
);
}
override render() {
return html`
<div class="adapters-container">
<affine-adapter-panel-header
.updateActiveContent=${this._updateActiveContent}
></affine-adapter-panel-header>
<affine-adapter-panel-body></affine-adapter-panel-body>
</div>
`;
}
@property({ attribute: false })
accessor store!: Store;
@property({ attribute: false })
accessor transformerMiddlewares: TransformerMiddleware[] = [];
@provide({ context: adapterPanelContext })
private accessor _context!: AdapterPanelContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_ADAPTER_PANEL]: AdapterPanel;
}
}

View File

@@ -0,0 +1,218 @@
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { SignalWatcher } from '@blocksuite/global/lit';
import { consume } from '@lit/context';
import { css, html, LitElement } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import {
type AdapterItem,
type AdapterPanelContext,
adapterPanelContext,
ADAPTERS,
} from '../config';
export const AFFINE_ADAPTER_PANEL_BODY = 'affine-adapter-panel-body';
export class AdapterPanelBody extends SignalWatcher(LitElement) {
static override styles = css`
.adapter-panel-body {
width: 100%;
height: calc(100% - 50px);
box-sizing: border-box;
overflow: auto;
padding: 8px 16px;
}
${scrollbarStyle('.adapter-panel-body')}
.adapter-content {
width: 100%;
height: 100%;
white-space: pre-wrap;
color: var(--affine-text-primary-color);
font-size: var(--affine-font-sm);
box-sizing: border-box;
}
.html-content {
display: flex;
gap: 8px;
flex-direction: column;
justify-content: space-between;
}
.html-preview-container,
.html-panel-content {
width: 100%;
flex: 1 0 0;
border: none;
box-sizing: border-box;
color: var(--affine-text-primary-color);
overflow: auto;
}
${scrollbarStyle('.html-panel-content')}
.html-panel-footer {
width: 100%;
height: 24px;
display: flex;
}
.html-toggle-container {
display: flex;
background: ${unsafeCSSVarV2('segment/background')};
justify-content: flex-start;
padding: 2px;
border-radius: 4px;
}
.html-toggle-item {
cursor: pointer;
display: flex;
padding: 0px 4px;
justify-content: center;
align-items: center;
font-size: 12px;
font-weight: 500;
line-height: 20px;
border-radius: 4px;
color: ${unsafeCSSVarV2('text/primary')};
}
.html-toggle-item:hover {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.html-toggle-item[active] {
background: ${unsafeCSSVarV2('segment/button')};
box-shadow:
var(--Shadow-buttonShadow-1-x, 0px) var(--Shadow-buttonShadow-1-y, 0px)
var(--Shadow-buttonShadow-1-blur, 1px) 0px
var(--Shadow-buttonShadow-1-color, rgba(0, 0, 0, 0.12)),
var(--Shadow-buttonShadow-2-x, 0px) var(--Shadow-buttonShadow-2-y, 1px)
var(--Shadow-buttonShadow-2-blur, 5px) 0px
var(--Shadow-buttonShadow-2-color, rgba(0, 0, 0, 0.12));
}
.adapter-container {
display: none;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.adapter-container.active {
display: block;
}
`;
get activeAdapter() {
return this._context.activeAdapter$.value;
}
get isHtmlPreview() {
return this._context.isHtmlPreview$.value;
}
get htmlContent() {
return this._context.htmlContent$.value;
}
get markdownContent() {
return this._context.markdownContent$.value;
}
get plainTextContent() {
return this._context.plainTextContent$.value;
}
get docSnapshot() {
return this._context.docSnapshot$.value;
}
private _renderHtmlPanel() {
return html`
${
this.isHtmlPreview
? html`<iframe
class="html-preview-container"
.srcdoc=${this.htmlContent}
sandbox="allow-same-origin"
></iframe>`
: html`<div class="html-panel-content">${this.htmlContent}</div>`
}
<div class="html-panel-footer">
<div class="html-toggle-container">
<span
class="html-toggle-item"
?active=${!this.isHtmlPreview}
@click=${() => (this._context.isHtmlPreview$.value = false)}
>Source</span
>
<span
class="html-toggle-item"
?active=${this.isHtmlPreview}
@click=${() => (this._context.isHtmlPreview$.value = true)}
>Preview</span
>
</div>
</div>
`;
}
private readonly _renderAdapterContent = (adapter: AdapterItem) => {
switch (adapter.id) {
case 'html':
return this._renderHtmlPanel();
case 'markdown':
return this.markdownContent;
case 'plaintext':
return this.plainTextContent;
case 'snapshot':
return this.docSnapshot
? JSON.stringify(this.docSnapshot, null, 4)
: '';
default:
return '';
}
};
private readonly _renderAdapterContainer = (adapter: AdapterItem) => {
const containerClasses = classMap({
'adapter-container': true,
active: this.activeAdapter.id === adapter.id,
});
const contentClasses = classMap({
'adapter-content': true,
[`${adapter.id}-content`]: true,
});
const content = this._renderAdapterContent(adapter);
return html`
<div class=${containerClasses}>
<div class=${contentClasses}>${content}</div>
</div>
`;
};
override render() {
return html`
<div class="adapter-panel-body">
${ADAPTERS.map(adapter => this._renderAdapterContainer(adapter))}
</div>
`;
}
@consume({ context: adapterPanelContext })
private accessor _context!: AdapterPanelContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_ADAPTER_PANEL_BODY]: AdapterPanelBody;
}
}

View File

@@ -0,0 +1,28 @@
import type { DocSnapshot } from '@blocksuite/store';
import { createContext } from '@lit/context';
import type { Signal } from '@preact/signals-core';
export type AdapterItem = {
id: string;
label: string;
};
export const ADAPTERS: AdapterItem[] = [
{ id: 'markdown', label: 'Markdown' },
{ id: 'plaintext', label: 'PlainText' },
{ id: 'html', label: 'HTML' },
{ id: 'snapshot', label: 'Snapshot' },
];
export type AdapterPanelContext = {
activeAdapter$: Signal<AdapterItem>;
isHtmlPreview$: Signal<boolean>;
docSnapshot$: Signal<DocSnapshot | null>;
htmlContent$: Signal<string>;
markdownContent$: Signal<string>;
plainTextContent$: Signal<string>;
};
export const adapterPanelContext = createContext<AdapterPanelContext>(
'adapterPanelContext'
);

View File

@@ -0,0 +1,17 @@
import { AdapterPanel, AFFINE_ADAPTER_PANEL } from './adapter-panel';
import {
AdapterPanelBody,
AFFINE_ADAPTER_PANEL_BODY,
} from './body/adapter-panel-body';
import { AdapterMenu, AFFINE_ADAPTER_MENU } from './header/adapter-menu';
import {
AdapterPanelHeader,
AFFINE_ADAPTER_PANEL_HEADER,
} from './header/adapter-panel-header';
export function effects() {
customElements.define(AFFINE_ADAPTER_PANEL, AdapterPanel);
customElements.define(AFFINE_ADAPTER_MENU, AdapterMenu);
customElements.define(AFFINE_ADAPTER_PANEL_HEADER, AdapterPanelHeader);
customElements.define(AFFINE_ADAPTER_PANEL_BODY, AdapterPanelBody);
}

View File

@@ -0,0 +1,86 @@
import { SignalWatcher } from '@blocksuite/global/lit';
import { consume } from '@lit/context';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import {
type AdapterItem,
type AdapterPanelContext,
adapterPanelContext,
ADAPTERS,
} from '../config';
export const AFFINE_ADAPTER_MENU = 'affine-adapter-menu';
export class AdapterMenu extends SignalWatcher(LitElement) {
static override styles = css`
.adapter-menu {
min-width: 120px;
padding: 4px;
background: var(--affine-background-primary-color);
border: 1px solid var(--affine-border-color);
border-radius: 4px;
box-shadow: var(--affine-shadow-1);
}
.adapter-menu-item {
display: block;
width: 100%;
padding: 6px 8px;
border: none;
background: none;
text-align: left;
cursor: pointer;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
border-radius: 4px;
}
.adapter-menu-item:hover {
background: var(--affine-hover-color);
}
.adapter-menu-item.active {
color: var(--affine-primary-color);
background: var(--affine-hover-color);
}
`;
get activeAdapter() {
return this._context.activeAdapter$.value;
}
private readonly _handleAdapterChange = async (adapter: AdapterItem) => {
this._context.activeAdapter$.value = adapter;
this.abortController?.abort();
};
override render() {
return html`<div class="adapter-menu">
${ADAPTERS.map(adapter => {
const classes = classMap({
'adapter-menu-item': true,
active: this.activeAdapter.id === adapter.id,
});
return html`
<button
class=${classes}
@click=${() => this._handleAdapterChange(adapter)}
>
${adapter.label}
</button>
`;
})}
</div>`;
}
@property({ attribute: false })
accessor abortController: AbortController | null = null;
@consume({ context: adapterPanelContext })
private accessor _context!: AdapterPanelContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_ADAPTER_MENU]: AdapterMenu;
}
}

View File

@@ -0,0 +1,119 @@
import { createLitPortal } from '@blocksuite/affine-components/portal';
import { SignalWatcher } from '@blocksuite/global/lit';
import { ArrowDownSmallIcon, FlipDirectionIcon } from '@blocksuite/icons/lit';
import { flip, offset } from '@floating-ui/dom';
import { consume } from '@lit/context';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { type AdapterPanelContext, adapterPanelContext } from '../config';
export const AFFINE_ADAPTER_PANEL_HEADER = 'affine-adapter-panel-header';
export class AdapterPanelHeader extends SignalWatcher(LitElement) {
static override styles = css`
.adapter-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--affine-background-primary-color);
}
.adapter-selector {
display: flex;
align-items: center;
justify-content: space-between;
width: 100px;
cursor: pointer;
border-radius: 4px;
border: 1px solid var(--affine-border-color);
padding: 4px 8px;
}
.adapter-selector:hover {
background: var(--affine-hover-color);
}
.adapter-selector-label {
display: flex;
align-items: center;
color: var(--affine-text-primary-color);
font-size: var(--affine-font-xs);
}
.update-button {
height: 20px;
width: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
color: var(--affine-icon-color);
}
.update-button:hover {
background-color: var(--affine-hover-color);
}
`;
get activeAdapter() {
return this._context.activeAdapter$.value;
}
private _adapterMenuAbortController: AbortController | null = null;
private readonly _toggleAdapterMenu = () => {
if (this._adapterMenuAbortController) {
this._adapterMenuAbortController.abort();
}
this._adapterMenuAbortController = new AbortController();
createLitPortal({
template: html`<affine-adapter-menu
.abortController=${this._adapterMenuAbortController}
></affine-adapter-menu>`,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
container: this._adapterPanelHeader,
computePosition: {
referenceElement: this._adapterSelector,
placement: 'bottom-start',
middleware: [flip(), offset(4)],
autoUpdate: { animationFrame: true },
},
abortController: this._adapterMenuAbortController,
closeOnClickAway: true,
});
};
override render() {
return html`
<div class="adapter-panel-header">
<div class="adapter-selector" @click="${this._toggleAdapterMenu}">
<span class="adapter-selector-label">
${this.activeAdapter.label}
</span>
${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
</div>
<div class="update-button" @click="${this.updateActiveContent}">
${FlipDirectionIcon({ width: '16px', height: '16px' })}
</div>
</div>
`;
}
@query('.adapter-panel-header')
private accessor _adapterPanelHeader!: HTMLDivElement;
@query('.adapter-selector')
private accessor _adapterSelector!: HTMLDivElement;
@property({ attribute: false })
accessor updateActiveContent: () => void = () => {};
@consume({ context: adapterPanelContext })
private accessor _context!: AdapterPanelContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_ADAPTER_PANEL_HEADER]: AdapterPanelHeader;
}
}

View File

@@ -0,0 +1,4 @@
export * from './adapter-panel.js';
export * from './body/adapter-panel-body.js';
export * from './header/adapter-menu.js';
export * from './header/adapter-panel-header.js';

View File

@@ -0,0 +1,12 @@
import { ViewExtensionProvider } from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
export class AdapterPanelViewExtension extends ViewExtensionProvider {
override name = 'affine-adapter-panel-fragment';
override effect() {
super.effect();
effects();
}
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/store" }
]
}

View File

@@ -0,0 +1,34 @@
{
"name": "@blocksuite/affine-fragment-doc-title",
"description": "Doc title fragment for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@preact/signals-core": "^1.8.0",
"lit": "^3.2.0"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,247 @@
import {
CodeBlockModel,
ListBlockModel,
NoteBlockModel,
NoteDisplayMode,
ParagraphBlockModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
import { focusTextModel, type RichText } from '@blocksuite/affine-rich-text';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { Store } from '@blocksuite/store';
import { effect } from '@preact/signals-core';
import { css, html } from 'lit';
import { property, query, state } from 'lit/decorators.js';
const DOC_BLOCK_CHILD_PADDING = 24;
export class DocTitle extends WithDisposable(ShadowlessElement) {
static override styles = css`
.doc-title-container {
font-size: 40px;
line-height: 50px;
font-weight: 700;
}
.doc-icon-container,
.doc-title-container {
box-sizing: border-box;
font-family: var(--affine-font-family);
color: var(--affine-text-primary-color);
outline: none;
resize: none;
border: 0;
width: 100%;
max-width: var(--affine-editor-width);
margin-left: auto;
margin-right: auto;
padding: 38px 0;
padding-left: var(
--affine-editor-side-padding,
${DOC_BLOCK_CHILD_PADDING}px
);
padding-right: var(
--affine-editor-side-padding,
${DOC_BLOCK_CHILD_PADDING}px
);
}
.doc-icon-container + * .doc-title-container {
/* when doc icon exists, remove the top padding */
padding-top: 0;
}
/* Extra small devices (phones, 640px and down) */
@container viewport (width <= 640px) {
.doc-icon-container,
.doc-title-container {
padding-left: ${DOC_BLOCK_CHILD_PADDING}px;
padding-right: ${DOC_BLOCK_CHILD_PADDING}px;
}
}
.doc-title-container-empty::before {
content: 'Title';
color: var(--affine-placeholder-color);
position: absolute;
opacity: 0.5;
pointer-events: none;
}
.doc-title-container:disabled {
background-color: transparent;
}
`;
private _getOrCreateFirstPageVisibleNote() {
const note = this._rootModel?.children.find(
(child): child is NoteBlockModel =>
matchModels(child, [NoteBlockModel]) &&
child.props.displayMode !== NoteDisplayMode.EdgelessOnly
);
if (note) return note;
const noteId = this.doc.addBlock('affine:note', {}, this._rootModel, 0);
return this.doc.getBlock(noteId)?.model as NoteBlockModel;
}
private readonly _onTitleKeyDown = (event: KeyboardEvent) => {
if (event.isComposing || this.doc.readonly) return;
if (!this._std) return;
if (event.key === 'Enter') {
this._std.event.active = true;
event.preventDefault();
event.stopPropagation();
const inlineRange = this.inlineEditor?.getInlineRange();
if (inlineRange) {
const rightText = this._rootModel?.props.title.split(inlineRange.index);
const newFirstParagraphId = this.doc.addBlock(
'affine:paragraph',
{ text: rightText },
this._getOrCreateFirstPageVisibleNote(),
0
);
if (this._std) focusTextModel(this._std, newFirstParagraphId);
}
} else if (event.key === 'ArrowDown') {
this._std.event.active = true;
event.preventDefault();
event.stopPropagation();
const note = this._getOrCreateFirstPageVisibleNote();
const firstText = note?.children.find(block =>
matchModels(block, [
ParagraphBlockModel,
ListBlockModel,
CodeBlockModel,
])
);
if (firstText) {
if (this._std) focusTextModel(this._std, firstText.id);
} else {
const newFirstParagraphId = this.doc.addBlock(
'affine:paragraph',
{},
note,
0
);
if (this._std) focusTextModel(this._std, newFirstParagraphId);
}
} else if (event.key === 'Tab') {
event.preventDefault();
event.stopPropagation();
}
};
private readonly _updateTitleInMeta = () => {
if (!this._rootModel) return;
this.doc.workspace.meta.setDocMeta(this.doc.id, {
title: this._rootModel.props.title.toString(),
});
};
private get _std() {
return this._viewport?.querySelector('editor-host')?.std;
}
private get _rootModel() {
return this.doc.root as RootBlockModel | null;
}
private get _viewport() {
return (
this.closest<HTMLElement>('.affine-page-viewport') ??
this.closest<HTMLElement>('.affine-edgeless-viewport')
);
}
get inlineEditor() {
return this._richTextElement.inlineEditor;
}
get inlineEditorContainer() {
return this._richTextElement.inlineEditorContainer;
}
override connectedCallback() {
super.connectedCallback();
this._isReadonly = this.doc.readonly;
this._disposables.add(
effect(() => {
if (this._isReadonly !== this.doc.readonly) {
this._isReadonly = this.doc.readonly;
this.requestUpdate();
}
})
);
this._disposables.addFromEvent(this, 'keydown', this._onTitleKeyDown);
// Workaround for inline editor skips composition event
this._disposables.addFromEvent(
this,
'compositionstart',
() => (this._isComposing = true)
);
this._disposables.addFromEvent(
this,
'compositionend',
() => (this._isComposing = false)
);
const updateMetaTitle = () => {
this._updateTitleInMeta();
this.requestUpdate();
};
if (this._rootModel) {
const rootModel = this._rootModel;
rootModel.props.title.yText.observe(updateMetaTitle);
this._disposables.add(() => {
rootModel.props.title.yText.unobserve(updateMetaTitle);
});
}
}
override render() {
const isEmpty = !this._rootModel?.props.title.length && !this._isComposing;
return html`
<div
class="doc-title-container ${
isEmpty ? 'doc-title-container-empty' : ''
}"
data-block-is-title="true"
>
<rich-text
.yText=${this._rootModel?.props.title.yText}
.undoManager=${this.doc.history.undoManager}
.verticalScrollContainerGetter=${() => this._viewport}
.readonly=${this.doc.readonly}
.enableFormat=${false}
.wrapText=${this.wrapText}
></rich-text>
</div>
`;
}
@state()
private accessor _isComposing = false;
@state()
private accessor _isReadonly = false;
@query('rich-text')
private accessor _richTextElement!: RichText;
@property({ attribute: false })
accessor doc!: Store;
@property({ attribute: false })
accessor wrapText = true;
}

View File

@@ -0,0 +1,11 @@
import { DocTitle } from './doc-title';
export function effects() {
customElements.define('doc-title', DocTitle);
}
declare global {
interface HTMLElementTagNameMap {
'doc-title': DocTitle;
}
}

View File

@@ -0,0 +1,2 @@
export { DocTitle } from './doc-title';
export { getDocTitleByEditorHost } from './utils';

View File

@@ -0,0 +1,11 @@
import type { EditorHost } from '@blocksuite/std';
import type { DocTitle } from './doc-title';
export function getDocTitleByEditorHost(
editorHost: EditorHost
): DocTitle | null {
const docViewport = editorHost.closest('.affine-page-viewport');
if (!docViewport) return null;
return docViewport.querySelector('doc-title');
}

View File

@@ -0,0 +1,12 @@
import { ViewExtensionProvider } from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
export class DocTitleViewExtension extends ViewExtensionProvider {
override name = 'affine-doc-title-fragment';
override effect() {
super.effect();
effects();
}
}

View File

@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}

View File

@@ -0,0 +1,39 @@
{
"name": "@blocksuite/affine-fragment-frame-panel",
"description": "Frame panel fragment for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-frame": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@toeverything/theme": "^1.1.23",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.23"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,442 @@
import { EdgelessFrameManager } from '@blocksuite/affine-block-frame';
import type { FrameBlockModel } from '@blocksuite/affine-model';
import {
DocModeProvider,
EditPropsStore,
} from '@blocksuite/affine-shared/services';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { Bound } from '@blocksuite/global/gfx';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { type EditorHost, ShadowlessElement } from '@blocksuite/std';
import {
generateKeyBetweenV2,
GfxControllerIdentifier,
} from '@blocksuite/std/gfx';
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.props.presentationIndex ?? frame.props.index,
cardIndex: idx,
}));
};
get frames() {
const frames = this.editorHost.store
.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}
.std=${this.editorHost.std}
.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]?.props.presentationIndex || null;
const after = frames[insertIndex]?.props.presentationIndex || null;
selectedFrames.forEach(frame => {
const newIndex = generateKeyBetweenV2(before, after);
frame.store.updateBlock(frame, {
presentationIndex: newIndex,
});
before = newIndex;
});
this.editorHost.store.captureSync();
this._updateFrames();
}
}
private _setDocDisposables(doc: Store) {
this._clearDocDisposables();
this._docDisposables = new DisposableGroup();
this._docDisposables.add(
doc.slots.blockUpdated.subscribe(({ 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.props.presentationIndex ?? frame.props.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.store);
// after switch to edgeless mode, should update the selection
if (this.editorHost.store.id === this._lastEdgelessRootId) {
this._gfx.selection.set({
elements: this._selected,
editing: false,
});
} else {
this._selected = this._selected.length ? [] : this._selected;
}
this._lastEdgelessRootId = this.editorHost.store.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;
}
}

View File

@@ -0,0 +1,126 @@
import type { FrameBlockModel } from '@blocksuite/affine-model';
import type { RichText } from '@blocksuite/affine-rich-text';
import { WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
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.subscribe(() => {
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.props.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;
}
}

View File

@@ -0,0 +1,151 @@
import type { FrameBlockModel } from '@blocksuite/affine-model';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
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.props.title}</span
>
</div>
</div>`;
}
override updated(_changedProperties: PropertyValues) {
if (_changedProperties.has('frame')) {
this._setFrameDisposables(this.frame.props.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;
}
}

View File

@@ -0,0 +1,258 @@
import type { FrameBlockModel } from '@blocksuite/affine-model';
import { on, once } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/std';
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 = styleMap(
this.status === 'dragging'
? {
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}
.std=${this.std}
></frame-preview>`
}
${this._DraggingCardNumber()}
</div>
</div>`;
}
@property({ attribute: false })
accessor std!: BlockStdScope;
@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;
}
}

View File

@@ -0,0 +1,263 @@
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine-ext-loader';
import type { FrameBlockModel } from '@blocksuite/affine-model';
import {
DocModeExtension,
DocModeProvider,
ViewportElementExtension,
} from '@blocksuite/affine-shared/services';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { Bound, deserializeXYWH } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import {
BlockStdScope,
type EditorHost,
LifeCycleWatcher,
ShadowlessElement,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { type ExtensionType, type Query, type Store } from '@blocksuite/store';
import { css, html, nothing, type PropertyValues } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js';
import { styleMap } from 'lit/directives/style-map.js';
import debounce from 'lodash-es/debounce';
const DEFAULT_PREVIEW_CONTAINER_WIDTH = 280;
const DEFAULT_PREVIEW_CONTAINER_HEIGHT = 166;
const styles = css`
.frame-preview-container {
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
position: relative;
}
.frame-preview-surface-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
overflow: hidden;
}
.frame-preview-viewport {
max-width: 100%;
box-sizing: border-box;
margin: 0 auto;
position: relative;
overflow: hidden;
pointer-events: none;
user-select: none;
}
.frame-preview-viewport
> editor-host
> affine-edgeless-root-preview
> .edgeless-background {
background-color: transparent;
background-image: none;
}
`;
export const AFFINE_FRAME_PREVIEW = 'frame-preview';
export class FramePreview extends WithDisposable(ShadowlessElement) {
static override styles = styles;
private readonly _clearFrameDisposables = () => {
this._frameDisposables?.dispose();
this._frameDisposables = null;
};
private readonly _docFilter: Query = {
mode: 'loose',
match: [
{
flavour: 'affine:frame',
viewType: 'hidden',
},
],
};
private _frameDisposables: DisposableGroup | null = null;
private _previewDoc: Store | null = null;
private _runtimePreviewExt: ExtensionType[] = [];
private get _viewExtensionManager() {
return this.std.get(ViewExtensionManagerIdentifier);
}
private get _previewSpec() {
return this._viewExtensionManager.get('preview-edgeless');
}
private readonly _updateFrameViewportWH = () => {
const [, , w, h] = deserializeXYWH(this.frame.xywh);
let scale = 1;
if (this.fillScreen) {
scale = Math.max(this.surfaceWidth / w, this.surfaceHeight / h);
} else {
scale = Math.min(this.surfaceWidth / w, this.surfaceHeight / h);
}
this.frameViewportWH = {
width: w * scale,
height: h * scale,
};
};
get _originalDoc() {
return this.frame.store;
}
private _initPreviewDoc() {
const doc = this._originalDoc.workspace.getDoc(this._originalDoc.id);
this._previewDoc =
doc?.getStore({ readonly: true, query: this._docFilter }) ?? null;
this.disposables.add(() => {
this._originalDoc.doc.removeStore({ query: this._docFilter });
});
}
private _initSpec() {
const refreshViewport = this._refreshViewport.bind(this);
class FramePreviewWatcher extends LifeCycleWatcher {
static override key = 'frame-preview-watcher';
override mounted() {
const std = this.std;
const { view } = std;
view.viewUpdated.subscribe(payload => {
if (
payload.type !== 'block' ||
payload.method !== 'add' ||
payload.view.model.flavour !== 'affine:page'
) {
return;
}
const viewport = std.get(GfxControllerIdentifier).viewport;
const subscription = viewport.sizeUpdated.subscribe(() => {
subscription.unsubscribe();
refreshViewport();
});
});
}
}
const docModeService = this.std.get(DocModeProvider);
this._runtimePreviewExt = [
ViewportElementExtension('.frame-preview-viewport'),
FramePreviewWatcher,
DocModeExtension(docModeService),
];
}
private _refreshViewport() {
const previewEditorHost = this.previewEditor;
if (!previewEditorHost) return;
const { viewport } = previewEditorHost.std.get(GfxControllerIdentifier);
const frameBound = Bound.deserialize(this.frame.xywh);
viewport.setViewportByBound(frameBound);
}
private _renderSurfaceContent() {
const { width, height } = this.frameViewportWH;
const _previewSpec = this._previewSpec.concat(this._runtimePreviewExt);
return html`<div
class="frame-preview-surface-container"
style=${styleMap({
width: `${this.surfaceWidth}px`,
height: `${this.surfaceHeight}px`,
})}
>
<div
class="frame-preview-viewport"
style=${styleMap({
width: `${width}px`,
height: `${height}px`,
})}
>
${guard([this._previewDoc, this.frame], () => {
if (!this._previewDoc || !this.frame) return nothing;
return new BlockStdScope({
store: this._previewDoc,
extensions: _previewSpec,
}).render();
})}
</div>
</div>`;
}
private _setFrameDisposables(frame: FrameBlockModel) {
this._clearFrameDisposables();
this._frameDisposables = new DisposableGroup();
this._frameDisposables.add(
frame.propsUpdated.subscribe(
debounce(this._updateFrameViewportWH, 10, { leading: true })
)
);
}
override connectedCallback() {
super.connectedCallback();
this._initSpec();
this._initPreviewDoc();
this._updateFrameViewportWH();
this._setFrameDisposables(this.frame);
}
override disconnectedCallback() {
super.disconnectedCallback();
this._clearFrameDisposables();
}
override render() {
const { frame } = this;
const noContent = !frame || !frame.xywh;
return html`<div class="frame-preview-container">
${noContent ? nothing : this._renderSurfaceContent()}
</div>`;
}
override updated(_changedProperties: PropertyValues) {
if (_changedProperties.has('frame')) {
this._setFrameDisposables(this.frame);
}
if (_changedProperties.has('frameViewportWH')) {
this._refreshViewport();
}
}
@property({ attribute: false })
accessor std!: BlockStdScope;
@state()
accessor fillScreen = false;
@property({ attribute: false })
accessor frame!: FrameBlockModel;
@state()
accessor frameViewportWH = {
width: 0,
height: 0,
};
@query('editor-host')
accessor previewEditor: EditorHost | null = null;
@property({ attribute: false })
accessor surfaceHeight: number = DEFAULT_PREVIEW_CONTAINER_HEIGHT;
@property({ attribute: false })
accessor surfaceWidth: number = DEFAULT_PREVIEW_CONTAINER_WIDTH;
}

View File

@@ -0,0 +1,34 @@
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_PREVIEW, FramePreview } from './card/frame-preview';
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);
customElements.define(AFFINE_FRAME_PREVIEW, FramePreview);
}

View File

@@ -0,0 +1,92 @@
import { WithDisposable } from '@blocksuite/global/lit';
import { type EditorHost, ShadowlessElement } from '@blocksuite/std';
import { baseTheme } from '@toeverything/theme';
import { css, html, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
const styles = css`
affine-frame-panel {
flex-grow: 1;
}
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;
}
}

View File

@@ -0,0 +1,248 @@
import {
type NavigatorMode,
PresentTool,
} 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 { DisposableGroup } from '@blocksuite/global/disposable';
import { WithDisposable } from '@blocksuite/global/lit';
import { PresentationIcon, SettingsIcon } from '@blocksuite/icons/lit';
import type { EditorHost } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
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(PresentTool, {
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.subscribe(({ 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({
reference: this._frameSettingButton,
popperElement: this._frameSettingMenu,
stateUpdated: ({ 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;
}
}

View File

@@ -0,0 +1,219 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import {
DocModeProvider,
EditPropsStore,
} from '@blocksuite/affine-shared/services';
import { WithDisposable } from '@blocksuite/global/lit';
import type { EditorHost } from '@blocksuite/std';
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.next({
blackBackground: this.blackBackground,
});
};
private readonly _onFillScreenChange = (checked: boolean) => {
this.fillScreen = checked;
this.slots.navigatorSettingUpdated.next({
fillScreen: this.fillScreen,
});
this._editPropsStore.setStorage('presentFillScreen', this.fillScreen);
};
private readonly _onHideToolBarChange = (checked: boolean) => {
this.hideToolbar = checked;
this.slots.navigatorSettingUpdated.next({
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.subscribe(
({ 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;
}
}

View File

@@ -0,0 +1 @@
export * from './frame-panel';

View File

@@ -0,0 +1,156 @@
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;
el.std = container.editorHost.std;
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;
}

View File

@@ -0,0 +1,12 @@
import { ViewExtensionProvider } from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
export class FramePanelViewExtension extends ViewExtensionProvider {
override name = 'affine-frame-panel-fragment';
override effect() {
super.effect();
effects();
}
}

View File

@@ -0,0 +1,20 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/frame" },
{ "path": "../../blocks/surface" },
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}

View File

@@ -0,0 +1,39 @@
{
"name": "@blocksuite/affine-fragment-outline",
"description": "Outline fragment for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-note": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-fragment-doc-title": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@vanilla-extract/css": "^1.17.0",
"lit": "^3.2.0"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,86 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const outlineNotice = style({
position: 'absolute',
left: 0,
bottom: '8px',
padding: '10px 18px',
display: 'flex',
width: '100%',
boxSizing: 'border-box',
gap: '14px',
fontStyle: 'normal',
fontSize: '12px',
flexDirection: 'column',
borderRadius: '8px',
backgroundColor: cssVar('--affine-background-overlay-panel-color'),
});
export const outlineNoticeHeader = style({
display: 'flex',
width: '100%',
height: '20px',
alignItems: 'center',
justifyContent: 'space-between',
});
export const outlineNoticeLabel = style({
fontWeight: 600,
lineHeight: '20px',
color: cssVarV2('text/secondary'),
});
export const outlineNoticeCloseButton = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px',
cursor: 'pointer',
color: cssVarV2('icon/primary'),
});
export const outlineNoticeBody = style({
display: 'flex',
width: '100%',
gap: '2px',
flexDirection: 'column',
});
const outlineNoticeItem = style({
display: 'flex',
height: '20px',
alignItems: 'center',
lineHeight: '20px',
color: cssVarV2('text/primary'),
});
export const notice = style([
outlineNoticeItem,
{
fontWeight: 400,
},
]);
export const button = style([
outlineNoticeItem,
{
display: 'flex',
gap: '2px',
fontWeight: 500,
textDecoration: 'underline',
cursor: 'pointer',
},
]);
export const buttonSpan = style({
display: 'flex',
alignItems: 'center',
lineHeight: '20px',
});
export const buttonSvg = style({
scale: 0.8,
});

View File

@@ -0,0 +1,94 @@
import { NoteDisplayMode } from '@blocksuite/affine-model';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { CloseIcon, SortIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { consume } from '@lit/context';
import { effect, signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { type TocContext, tocContext } from '../config';
import { getNotesFromStore } from '../utils/query';
import * as styles from './outline-notice.css';
export const AFFINE_OUTLINE_NOTICE = 'affine-outline-notice';
export class OutlineNotice extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
private readonly _visible$ = signal(false);
override connectedCallback(): void {
super.connectedCallback();
this.disposables.add(
effect(() => {
const enableSorting = this._context.enableSorting$.value;
if (enableSorting) {
if (this._visible$.peek()) {
this._visible$.value = false;
}
return;
}
const shouldShowNotice =
getNotesFromStore(this._context.editor$.value.store, [
NoteDisplayMode.DocOnly,
]).length > 0;
if (shouldShowNotice && !this._visible$.peek()) {
this._visible$.value = true;
}
})
);
}
override render() {
if (!this._visible$.value) {
return nothing;
}
return html`
<div data-testid=${AFFINE_OUTLINE_NOTICE} class=${styles.outlineNotice}>
<div class=${styles.outlineNoticeHeader}>
<span class=${styles.outlineNoticeLabel}>SOME CONTENTS HIDDEN</span>
<span
data-testid="outline-notice-close-button"
class=${styles.outlineNoticeCloseButton}
@click=${() => {
this._visible$.value = false;
}}
>${CloseIcon({ width: '16px', height: '16px' })}</span
>
</div>
<div class=${styles.outlineNoticeBody}>
<div class="${styles.notice}">
Some contents are not visible on edgeless.
</div>
<div
data-testid="outline-notice-sort-button"
class="${styles.button}"
@click=${() => {
this._context.enableSorting$.value = true;
this._visible$.value = false;
}}
>
<span class=${styles.buttonSpan}>Click here or</span>
<span class=${styles.buttonSpan}
>${SortIcon({ width: '20px', height: '20px' })}</span
>
<span class=${styles.buttonSpan}>to organize content.</span>
</div>
</div>
</div>
`;
}
@consume({ context: tocContext })
private accessor _context!: TocContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_OUTLINE_NOTICE]: OutlineNotice;
}
}

View File

@@ -0,0 +1,62 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const outlinePanelBody = style({
position: 'relative',
alignItems: 'start',
boxSizing: 'border-box',
width: '100%',
height: '100%',
padding: '0 8px',
flexGrow: 1,
overflowY: 'scroll',
});
export const cardList = style({
position: 'relative',
});
export const edgelessCardListTitle = style({
width: '100%',
fontSize: '14px',
lineHeight: '24px',
fontWeight: 500,
color: cssVarV2('text/secondary'),
paddingLeft: '8px',
height: '40px',
boxSizing: 'border-box',
padding: '6px 8px',
marginTop: '8px',
});
export const insertIndicator = style({
height: '2px',
borderRadius: '1px',
backgroundColor: cssVar('brandColor'),
position: 'absolute',
top: 0,
left: 0,
right: 0,
contain: 'layout size',
width: '100%',
});
export const emptyPanel = style({
display: 'flex',
flexDirection: 'column',
width: '100%',
});
export const emptyPanelPlaceholder = style({
marginTop: '240px',
alignSelf: 'center',
width: '190px',
height: '48px',
color: cssVarV2('text/secondary'),
textAlign: 'center',
fontSize: '15px',
fontStyle: 'normal',
fontWeight: 400,
lineHeight: '24px',
});

View File

@@ -0,0 +1,483 @@
import { changeNoteDisplayMode } from '@blocksuite/affine-block-note';
import { NoteBlockModel, NoteDisplayMode } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { focusTitle, matchModels } from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/gfx';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
BlockSelection,
ShadowlessElement,
SurfaceSelection,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import { consume } from '@lit/context';
import {
batch,
computed,
effect,
type Signal,
signal,
} from '@preact/signals-core';
import { html, nothing } from 'lit';
import { query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { when } from 'lit/directives/when.js';
import { type TocContext, tocContext } from '../config';
import type {
ClickBlockEvent,
DisplayModeChangeEvent,
FitViewEvent,
SelectEvent,
} from '../utils/custom-events';
import type { NoteCardEntity, NoteDropPayload } from '../utils/drag';
import {
getHeadingBlocksFromDoc,
getNotesFromStore,
isHeadingBlock,
} from '../utils/query';
import {
observeActiveHeadingDuringScroll,
scrollToBlockWithHighlight,
} from '../utils/scroll';
import * as styles from './outline-panel-body.css';
export const AFFINE_OUTLINE_PANEL_BODY = 'affine-outline-panel-body';
export class OutlinePanelBody extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
private readonly _activeHeadingId$ = signal<string | null>(null);
private readonly _dragging$ = signal(false);
private readonly _indicatorTranslateY$ = signal(0);
private readonly _pageVisibleNotes$ = signal<NoteBlockModel[]>([]);
private readonly _edgelessOnlyNotes$ = signal<NoteBlockModel[]>([]);
private readonly _selectedNotes$: Record<
NoteDisplayMode,
Signal<NoteBlockModel[]>
> = {
[NoteDisplayMode.DocOnly]: signal<NoteBlockModel[]>([]),
[NoteDisplayMode.DocAndEdgeless]: signal<NoteBlockModel[]>([]),
[NoteDisplayMode.EdgelessOnly]: signal<NoteBlockModel[]>([]),
};
private readonly _allSelectedNotes$ = computed(() =>
[
NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly,
NoteDisplayMode.EdgelessOnly,
].flatMap(mode => this._selectedNotes$[mode].value)
);
private _clearHighlightMask = () => {};
private _lockActiveHeadingId = false;
private get _shouldRenderEmptyPanel() {
return (
this._pageVisibleNotes$.value.length === 0 &&
this._edgelessOnlyNotes$.value.length === 0
);
}
private get editor() {
return this._context.editor$.value;
}
private get store() {
return this.editor.store;
}
get viewportPadding(): [number, number, number, number] {
const fitPadding = this._context.fitPadding$.value;
return fitPadding.length === 4
? ([0, 0, 0, 0].map((val, idx) =>
Number.isFinite(fitPadding[idx]) ? fitPadding[idx] : val
) as [number, number, number, number])
: [0, 0, 0, 0];
}
private _deSelectNoteInEdgelessMode(note: NoteBlockModel) {
const gfx = this.editor.std.get(GfxControllerIdentifier);
const selection = gfx.selection;
if (!selection.has(note.id)) return;
const selectedIds = selection.selectedIds.filter(id => id !== note.id);
selection.set({
elements: selectedIds,
editing: false,
});
}
private _renderEmptyPanel() {
return html`<div class=${styles.emptyPanel}>
<div
data-testid="empty-panel-placeholder"
class=${styles.emptyPanelPlaceholder}
>
Use headings to create a table of contents.
</div>
</div>`;
}
private _fitToElement(e: FitViewEvent) {
const gfx = this.editor.std.get(GfxControllerIdentifier);
const { block } = e.detail;
const bound = Bound.deserialize(block.xywh);
gfx.viewport.setViewportByBound(bound, this.viewportPadding, true);
}
// when display mode change to page only, we should de-select the note if it is selected in edgeless mode
private _handleDisplayModeChange(e: DisplayModeChangeEvent) {
const { note, newMode } = e.detail;
this.editor.std.command.exec(changeNoteDisplayMode, {
noteId: note.id,
mode: newMode,
stopCapture: true,
});
// When the display mode of a note changed to page only
// We should check if the note is selected in edgeless mode
// If so, we should de-select it
if (newMode === NoteDisplayMode.DocOnly) {
this._deSelectNoteInEdgelessMode(note);
}
}
private _moveSelectedNotes(insertIndex: number) {
if (!this.store.root) return;
const pageVisibleNotes = this._pageVisibleNotes$.peek();
const selected = this._allSelectedNotes$.peek();
const children = this.store.root.children.slice();
const noteIndex = new Map<NoteBlockModel, number>();
children.forEach((block, index) => {
if (matchModels(block, [NoteBlockModel])) {
noteIndex.set(block, index);
}
});
let targetIndex: number | null = null;
if (insertIndex === pageVisibleNotes.length) {
const temp = noteIndex.get(pageVisibleNotes[insertIndex - 1]);
if (temp) targetIndex = temp + 1;
} else {
targetIndex = noteIndex.get(pageVisibleNotes[insertIndex]) ?? null;
}
if (targetIndex === null) return;
const removeSelectedNoteFilter = (block: BlockModel) =>
!matchModels(block, [NoteBlockModel]) || !selected.includes(block);
const leftPart = children
.slice(0, targetIndex)
.filter(removeSelectedNoteFilter);
const rightPart = children
.slice(targetIndex)
.filter(removeSelectedNoteFilter);
const newChildren = [...leftPart, ...selected, ...rightPart];
this.store.updateBlock(this.store.root, {
children: newChildren,
});
}
private async _scrollToBlock(blockId: string) {
// if focus title
if (blockId === this.store.root?.id) {
this.editor.std.selection.setGroup('note', []);
this.editor.std.event.active = false;
focusTitle(this.editor);
} else {
this.editor.std.event.active = true;
this.editor.std.selection.setGroup('note', [
this.editor.std.selection.create(BlockSelection, {
blockId,
}),
]);
}
this._lockActiveHeadingId = true;
this._activeHeadingId$.value = blockId;
this._clearHighlightMask = await scrollToBlockWithHighlight(
this.editor,
blockId
);
this._lockActiveHeadingId = false;
}
private _selectNote(e: SelectEvent) {
const { selected, id, multiselect } = e.detail;
const gfx = this.editor.std.get(GfxControllerIdentifier);
const editorMode = this.editor.std.get(DocModeProvider).getEditorMode();
const note = this.store.getBlock(id)?.model;
if (!note || !matchModels(note, [NoteBlockModel])) return;
// map from signal to value
const selectedNotes = Object.fromEntries(
Object.entries(this._selectedNotes$).map(([k, v]) => [k, v.peek()])
) as Record<NoteDisplayMode, NoteBlockModel[]>;
if (multiselect) {
selectedNotes[note.props.displayMode] = selected
? [...selectedNotes[note.props.displayMode], note]
: selectedNotes[note.props.displayMode].filter(_note => _note !== note);
} else {
selectedNotes[note.props.displayMode] = selected ? [note] : [];
Object.keys(this._selectedNotes$).forEach(mode => {
if (mode !== note.props.displayMode) {
selectedNotes[mode as NoteDisplayMode] = [];
}
});
}
// We use gfx.selection and effect to keep sync between canvas and outline panel
if (editorMode === 'edgeless') {
gfx.selection.set({
elements: [...selectedNotes.both, ...selectedNotes.edgeless].map(
({ id }) => id
),
editing: false,
});
this._selectedNotes$.doc.value = selectedNotes.doc;
} else {
[NoteDisplayMode.DocOnly, NoteDisplayMode.DocAndEdgeless].forEach(
mode => {
this._selectedNotes$[mode].value = selectedNotes[mode];
}
);
}
}
private _watchSelectedNotes() {
return effect(() => {
const { std, store } = this.editor;
const docModeService = this.editor.std.get(DocModeProvider);
const mode = docModeService.getEditorMode();
if (mode !== 'edgeless') return;
const currSelectedNotes = std.selection
.filter(SurfaceSelection)
.map(({ blockId }) => store.getBlock(blockId)?.model)
.filter(model => {
return !!model && matchModels(model, [NoteBlockModel]);
});
// update selected notes from edgeless selection
batch(() => {
[NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.EdgelessOnly].forEach(
mode => {
this._selectedNotes$[mode].value = currSelectedNotes.filter(
note => note.props.displayMode === mode
);
}
);
});
});
}
private _watchNotes() {
this.disposables.add(
effect(() => {
const isRenderableNote = (note: NoteBlockModel) => {
let hasHeadings = false;
for (const block of note.children) {
if (isHeadingBlock(block)) {
hasHeadings = true;
break;
}
}
return hasHeadings || this._context.enableSorting$.value;
};
this._pageVisibleNotes$.value = getNotesFromStore(this.store, [
NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly,
]).filter(isRenderableNote);
this._edgelessOnlyNotes$.value = getNotesFromStore(this.store, [
NoteDisplayMode.EdgelessOnly,
]).filter(isRenderableNote);
})
);
}
private _watchDragAndDrop() {
const std = this.editor.std;
this.disposables.add(
std.dnd.monitor<NoteCardEntity, NoteDropPayload>({
onDragStart: () => {
this._dragging$.value = true;
},
onDrag: data => {
const target = data.location.current.dropTargets[0];
if (!target) return;
const edge = target.data.edge;
const rect = target.element.getBoundingClientRect();
const parentRect = this._pageVisibleList.getBoundingClientRect();
this._indicatorTranslateY$.value =
edge === 'top'
? rect.top - parentRect.top
: rect.bottom - parentRect.top;
},
onDrop: data => {
this._dragging$.value = false;
const target = data.location.current.dropTargets[0];
if (!target) return;
const edge = target.data.edge;
const index = this._pageVisibleNotes$
.peek()
.findIndex(({ id }) => id === target.data.noteId);
if (index === -1) return;
this._moveSelectedNotes(edge === 'top' ? index : index + 1);
},
})
);
this.disposables.add(
std.dnd.autoScroll<NoteCardEntity>({
element: this,
})
);
}
override connectedCallback(): void {
super.connectedCallback();
this.classList.add(styles.outlinePanelBody);
this.disposables.add(
observeActiveHeadingDuringScroll(
() => this.editor,
newHeadingId => {
if (this._lockActiveHeadingId) return;
this._activeHeadingId$.value = newHeadingId;
}
)
);
this._watchNotes();
this._watchSelectedNotes();
this._watchDragAndDrop();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this._clearHighlightMask();
}
private _renderDocTitle() {
if (!this.store.root) return nothing;
const hasNotEmptyHeadings =
getHeadingBlocksFromDoc(
this.store,
[NoteDisplayMode.DocOnly, NoteDisplayMode.DocAndEdgeless],
true
).length > 0;
if (!hasNotEmptyHeadings) return nothing;
const rootId = this.store.root.id;
const active = rootId === this._activeHeadingId$.value;
return html`<affine-outline-block-preview
class=${classMap({ active: active })}
.block=${this.store.root}
@click=${() => {
this._scrollToBlock(rootId).catch(console.error);
}}
></affine-outline-block-preview>`;
}
private _renderNoteCards(notes: NoteBlockModel[]) {
return repeat(
notes,
({ id }) => id,
(note, index) =>
html`<affine-outline-note-card
data-note-id=${note.id}
index=${index}
.note=${note}
.activeHeadingId=${this._activeHeadingId$.value}
.status=${
this._allSelectedNotes$.value.includes(note)
? this._dragging$.value
? 'dragging'
: 'selected'
: 'normal'
}
@fitview=${this._fitToElement}
@select=${this._selectNote}
@displaymodechange=${this._handleDisplayModeChange}
@clickblock=${(e: ClickBlockEvent) => {
this._scrollToBlock(e.detail.blockId).catch(console.error);
}}
></affine-outline-note-card>`
);
}
private _renderPageVisibleCardList() {
return html`<div class=${`page-visible-card-list ${styles.cardList}`}>
${when(
this._dragging$.value,
() =>
html`<div
class=${styles.insertIndicator}
style=${`transform: translateY(${this._indicatorTranslateY$.value}px)`}
></div>`
)}
${this._renderNoteCards(this._pageVisibleNotes$.value)}
</div>`;
}
private _renderEdgelessOnlyCardList() {
const items = this._edgelessOnlyNotes$.value;
return html`<div class=${styles.cardList}>
${when(
items.length > 0,
() =>
html`<div class=${styles.edgelessCardListTitle}>Hidden Contents</div>`
)}
${this._renderNoteCards(items)}
</div>`;
}
override render() {
return html`
${this._renderDocTitle()}
${when(
this._shouldRenderEmptyPanel,
() => this._renderEmptyPanel(),
() => html`
${this._renderPageVisibleCardList()}
${this._renderEdgelessOnlyCardList()}
`
)}
`;
}
@query('.page-visible-card-list')
private accessor _pageVisibleList!: HTMLElement;
@consume({ context: tocContext })
private accessor _context!: TocContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_OUTLINE_PANEL_BODY]: OutlinePanelBody;
}
}

View File

@@ -0,0 +1,166 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const outlineCard = style({
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
selectors: {
'&[data-status="dragging"]': {
pointerEvents: 'none',
opacity: 0.5,
},
'&[data-sortable="true"]': {
padding: '2px 0px',
},
},
});
export const cardPreview = style({
position: 'relative',
width: '100%',
borderRadius: '4px',
cursor: 'default',
userSelect: 'none',
selectors: {
[`${outlineCard}[data-sortable="true"] &:hover`]: {
background: cssVarV2('layer/background/hoverOverlay'),
},
[`${outlineCard}[data-status="selected"] &`]: {
background: cssVarV2('layer/background/hoverOverlay'),
},
[`${outlineCard}[data-status="dragging"] &`]: {
background: cssVarV2('layer/background/hoverOverlay'),
opacity: 0.9,
},
},
});
export const cardHeader = style({
padding: '0 8px',
width: '100%',
minHeight: '28px',
display: 'none',
alignItems: 'center',
gap: '8px',
boxSizing: 'border-box',
':hover': {
cursor: 'grab',
},
selectors: {
[`${outlineCard}[data-sortable="true"] &`]: {
display: 'flex',
},
[`${outlineCard}[data-visibility="edgeless"] &:hover`]: {
cursor: 'default',
},
},
});
const invisibleCard = style({
selectors: {
[`${outlineCard}[data-visibility="edgeless"] &`]: {
color: cssVarV2('text/disable'),
pointerEvents: 'none',
},
},
});
export const headerIcon = style([
{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
invisibleCard,
]);
export const headerNumber = style([
{
textAlign: 'center',
fontSize: cssVar('fontSm'),
color: cssVar('brandColor'),
fontWeight: 500,
lineHeight: '14px',
},
invisibleCard,
]);
export const divider = style({
height: '1px',
flex: 1,
borderTop: `1px dashed ${cssVar('borderColor')}`,
transform: 'translateY(50%)',
});
export const displayModeButtonGroup = style({
display: 'none',
position: 'absolute',
right: '8px',
top: '-6px',
paddingTop: '8px',
paddingBottom: '8px',
alignItems: 'center',
gap: '4px',
fontSize: '12px',
fontWeight: 500,
lineHeight: '20px',
selectors: {
[`${cardPreview}:hover &`]: {
display: 'flex',
},
},
});
export const displayModeButton = style({
display: 'flex',
borderRadius: '4px',
backgroundColor: cssVar('hoverColor'),
alignItems: 'center',
});
export const currentModeLabel = style({
display: 'flex',
padding: '2px 0px 2px 4px',
alignItems: 'center',
});
export const cardContent = style([
{
fontFamily: cssVar('fontSansFamily'),
userSelect: 'none',
color: cssVarV2('text/primary'),
':hover': {
cursor: 'pointer',
},
},
invisibleCard,
]);
export const modeChangePanel = style({
position: 'absolute',
display: 'none',
background: cssVarV2('layer/background/overlayPanel'),
borderRadius: '8px',
boxShadow: cssVar('shadow2'),
boxSizing: 'border-box',
padding: '8px',
fontSize: cssVar('fontSm'),
color: cssVarV2('text/primary'),
lineHeight: '22px',
fontWeight: 400,
fontFamily: cssVar('fontSansFamily'),
selectors: {
'&[data-show]': {
display: 'flex',
},
},
});

View File

@@ -0,0 +1,278 @@
import { type NoteBlockModel, NoteDisplayMode } from '@blocksuite/affine-model';
import { createButtonPopper } from '@blocksuite/affine-shared/utils';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ArrowDownSmallIcon, InvisibleIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import { consume, ContextProvider } from '@lit/context';
import { signal } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { html } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { type TocContext, tocContext } from '../config';
import type { SelectEvent } from '../utils/custom-events';
import type { NoteCardEntity, NoteDropPayload } from '../utils/drag';
import * as styles from './outline-card.css';
export const AFFINE_OUTLINE_NOTE_CARD = 'affine-outline-note-card';
export class OutlineNoteCard extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
private _displayModePopper: ReturnType<typeof createButtonPopper> | null =
null;
private readonly _showPopper$ = signal(false);
private _dispatchClickBlockEvent(block: BlockModel) {
const event = new CustomEvent('clickblock', {
detail: {
blockId: block.id,
},
});
this.dispatchEvent(event);
}
private _dispatchDisplayModeChangeEvent(newMode: NoteDisplayMode) {
const event = new CustomEvent('displaymodechange', {
detail: {
note: this.note,
newMode,
},
});
this.dispatchEvent(event);
}
private _dispatchFitViewEvent(e: MouseEvent) {
e.stopPropagation();
const event = new CustomEvent('fitview', {
detail: {
block: this.note,
},
});
this.dispatchEvent(event);
}
private _dispatchSelectEvent(e: MouseEvent) {
e.stopPropagation();
const event = new CustomEvent('select', {
detail: {
id: this.note.id,
selected: this.status !== 'selected',
multiselect: e.shiftKey,
},
}) as SelectEvent;
this.dispatchEvent(event);
}
private _getCurrentModeLabel(mode: NoteDisplayMode) {
switch (mode) {
case NoteDisplayMode.DocAndEdgeless:
return 'Both';
case NoteDisplayMode.EdgelessOnly:
return 'Edgeless';
case NoteDisplayMode.DocOnly:
return 'Page';
default:
return 'Both';
}
}
private _watchDragEvents() {
const std = this._context.editor$.value.std;
this.disposables.add(
std.dnd.draggable<NoteCardEntity>({
element: this,
canDrag: () =>
this.note.props.displayMode !== NoteDisplayMode.EdgelessOnly,
onDragStart: () => {
if (this.status !== 'selected') {
this.dispatchEvent(
new CustomEvent('select', {
detail: {
id: this.note.id,
selected: true,
multiselect: false,
},
})
);
}
},
setDragData: () => ({
type: 'toc-card',
noteId: this.note.id,
}),
setDragPreview: ({ container, setOffset, location }) => {
const preview = document.createElement(AFFINE_OUTLINE_NOTE_CARD);
preview.note = this.note;
preview.index = this.index;
preview._context = this._context;
const { clientX, clientY } = location.current.input;
const { left, top } = this.getBoundingClientRect();
setOffset({ x: clientX - left, y: clientY - top });
container.style.width = `${this.parentElement?.clientWidth ?? 0}px`;
container.style.maxHeight = '500px';
container.style.overflow = 'hidden';
container.style.backgroundColor = cssVarV2(
'layer/background/primary'
);
container.append(preview);
const provider = new ContextProvider(container, {
context: tocContext,
initialValue: this._context,
});
provider.hostConnected();
},
})
);
this.disposables.add(
std.dnd.dropTarget<NoteCardEntity, NoteDropPayload>({
element: this,
setDropData: () => ({
noteId: this.note.id,
}),
canDrop: () => {
return this.note.props.displayMode !== NoteDisplayMode.EdgelessOnly;
},
})
);
}
override connectedCallback() {
super.connectedCallback();
this._watchDragEvents();
}
override firstUpdated() {
this._displayModePopper = createButtonPopper({
reference: this._displayModeButtonGroup,
popperElement: this._displayModePanel,
stateUpdated: ({ display }) => {
this._showPopper$.value = display === 'show';
},
mainAxis: 0,
crossAxis: -60,
});
this.disposables.add(this._displayModePopper);
}
override render() {
const { displayMode } = this.note.props;
const { children } = this.note;
const currentMode = this._getCurrentModeLabel(displayMode);
const invisible =
this.note.props.displayMode$.value === NoteDisplayMode.EdgelessOnly;
const enableSorting = this._context.enableSorting$.value;
return html`
<div
data-visibility=${this.note.props.displayMode}
data-sortable=${enableSorting}
data-status=${this.status}
class=${styles.outlineCard}
>
<div
class=${styles.cardPreview}
@click=${this._dispatchSelectEvent}
@dblclick=${this._dispatchFitViewEvent}
>
${html`<div class=${styles.cardHeader}>
${
invisible
? html`<span class=${styles.headerIcon}
>${InvisibleIcon({ width: '20px', height: '20px' })}</span
>`
: html`<span class=${styles.headerNumber}
>${this.index + 1}</span
>`
}
<span class=${styles.divider}></span>
<div class=${styles.displayModeButtonGroup}>
<span>Show in</span>
<edgeless-tool-icon-button
.tooltip=${this._showPopper$.value ? '' : 'Display Mode'}
.tipPosition=${'left-start'}
.iconContainerPadding=${0}
data-testid="display-mode-button"
@click=${(e: MouseEvent) => {
e.stopPropagation();
this._displayModePopper?.toggle();
}}
@dblclick=${(e: MouseEvent) => e.stopPropagation()}
>
<div class=${styles.displayModeButton}>
<span class=${styles.currentModeLabel}>${currentMode}</span>
${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
</div>
</edgeless-tool-icon-button>
</div>
</div>
<note-display-mode-panel
class=${styles.modeChangePanel}
.displayMode=${displayMode}
.panelWidth=${220}
.onSelect=${(newMode: NoteDisplayMode) => {
this._dispatchDisplayModeChangeEvent(newMode);
this._displayModePopper?.hide();
}}
>
</note-display-mode-panel>
</div>`}
<div class=${styles.cardContent}>
${children.map(block => {
return html`<affine-outline-block-preview
class=${classMap({ active: this.activeHeadingId === block.id })}
.block=${block}
.disabledIcon=${invisible}
@click=${() => {
if (invisible) return;
this._dispatchClickBlockEvent(block);
}}
></affine-outline-block-preview>`;
})}
</div>
</div>
</div>
</div>
`;
}
@query(`.${styles.displayModeButtonGroup}`)
private accessor _displayModeButtonGroup!: HTMLDivElement;
@query('note-display-mode-panel')
private accessor _displayModePanel!: HTMLDivElement;
@property({ attribute: false })
accessor activeHeadingId: string | null = null;
@property({ attribute: false })
accessor note!: NoteBlockModel;
@property({ attribute: true, type: Number })
accessor index: number = 0;
@property({ attribute: false })
accessor status: 'selected' | 'dragging' | 'normal' = 'normal';
@consume({ context: tocContext })
private accessor _context!: TocContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_OUTLINE_NOTE_CARD]: OutlineNoteCard;
}
}

View File

@@ -0,0 +1,119 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const outlineBlockPreview = style({
fontFamily: cssVar('fontFamily'),
width: '100%',
boxSizing: 'border-box',
padding: '6px 8px',
whiteSpace: 'nowrap',
display: 'flex',
justifyContent: 'start',
alignItems: 'center',
gap: '8px',
':hover': {
cursor: 'pointer',
background: cssVarV2('layer/background/hoverOverlay'),
},
selectors: {
'.active > &': {
color: cssVarV2('text/emphasis'),
},
'&:not(:has(span))': {
display: 'none',
},
},
});
export const icon = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '22px',
height: '22px',
boxSizing: 'border-box',
padding: '4px',
background: cssVarV2('layer/background/secondary'),
borderRadius: '4px',
color: cssVarV2('icon/primary'),
});
export const iconDisabled = style({
color: cssVarV2('icon/disable'),
});
export const text = style({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
fontSize: cssVar('fontSm'),
lineHeight: '22px',
height: '22px',
});
export const textGeneral = style({
fontWeight: 400,
paddingLeft: '28px',
});
export const subtypeStyles = {
title: style({
fontWeight: 600,
paddingLeft: '0',
}),
h1: style({
fontWeight: 600,
paddingLeft: '0',
}),
h2: style({
fontWeight: 600,
paddingLeft: '4px',
}),
h3: style({
fontWeight: 600,
paddingLeft: '12px',
}),
h4: style({
fontWeight: 600,
paddingLeft: '16px',
}),
h5: style({
fontWeight: 600,
paddingLeft: '20px',
}),
h6: style({
fontWeight: 600,
paddingLeft: '24px',
}),
};
export const textSpan = style({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const linkedDocText = style({
fontSize: 'inherit',
borderBottom: `0.5px solid ${cssVar('dividerColor')}`,
whiteSpace: 'break-spaces',
marginRight: '2px',
});
export const linkedDocPreviewUnavailable = style({
color: cssVarV2('text/disable'),
});
export const linkedDocPreviewAvailable = style({});
globalStyle(`${linkedDocPreviewAvailable} > svg`, {
marginBottom: '0.1em',
});
export const linkedDocTextUnavailable = style({
color: cssVarV2('text/disable'),
textDecoration: 'line-through',
});

View File

@@ -0,0 +1,246 @@
import type {
AttachmentBlockModel,
BookmarkBlockModel,
CodeBlockModel,
DatabaseBlockModel,
ImageBlockModel,
ListBlockModel,
ParagraphBlockModel,
RootBlockModel,
} from '@blocksuite/affine-model';
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { noop } from '@blocksuite/global/utils';
import { LinkedPageIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { BlockModel, DeltaInsert } from '@blocksuite/store';
import { consume } from '@lit/context';
import { html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import {
placeholderMap,
previewIconMap,
type TocContext,
tocContext,
} from '../config.js';
import { isHeadingBlock, isRootBlock } from '../utils/query.js';
import * as styles from './outline-preview.css';
function assertType<T>(value: unknown): asserts value is T {
noop(value);
}
export const AFFINE_OUTLINE_BLOCK_PREVIEW = 'affine-outline-block-preview';
export class OutlineBlockPreview extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
private get _docDisplayMetaService() {
return this._context.editor$.value.std.get(DocDisplayMetaProvider);
}
private _TextBlockPreview(block: ParagraphBlockModel | ListBlockModel) {
const deltas: DeltaInsert<AffineTextAttributes>[] =
block.props.text.yText.toDelta();
if (!block.props.text.length) return nothing;
const iconClass = this.disabledIcon ? styles.iconDisabled : styles.icon;
const previewText = deltas.map(delta => {
if (delta.attributes?.reference) {
// If linked doc, render linked doc icon and the doc title.
const refAttribute = delta.attributes.reference;
const refMeta = block.store.workspace.meta.docMetas.find(
doc => doc.id === refAttribute.pageId
);
const unavailable = !refMeta;
const icon = unavailable
? LinkedPageIcon({ width: '1.1em', height: '1.1em' })
: this._docDisplayMetaService.icon(refMeta.id).value;
const title = unavailable
? 'Deleted doc'
: this._docDisplayMetaService.title(refMeta.id).value;
return html`<span
class=${classMap({
[styles.linkedDocPreviewUnavailable]: unavailable,
[styles.linkedDocPreviewAvailable]: !unavailable,
})}
>
${icon}
<span
class=${classMap({
[styles.linkedDocText]: true,
[styles.linkedDocTextUnavailable]: unavailable,
})}
>${title.length ? title : 'Untitled'}</span
></span
>`;
} else {
// If not linked doc, render the text.
return delta.insert.toString().trim().length > 0
? html`<span class=${styles.textSpan}
>${delta.insert.toString()}</span
>`
: nothing;
}
});
const headingClass =
block.props.type in styles.subtypeStyles
? styles.subtypeStyles[
block.props.type as keyof typeof styles.subtypeStyles
]
: '';
return html`<span
data-testid="outline-block-preview-${block.props.type}"
class="${styles.text} ${styles.textGeneral} ${headingClass}"
>${previewText}</span
>
${
this._context.showIcons$.value
? html`<span class=${iconClass}
>${previewIconMap[block.props.type]}</span
>`
: nothing
}`;
}
override render() {
return html`<div class=${styles.outlineBlockPreview}>
${this.renderBlockByFlavour()}
</div>`;
}
renderBlockByFlavour() {
const { block } = this;
const iconClass = this.disabledIcon ? styles.iconDisabled : styles.icon;
if (
!this._context.enableSorting$.value &&
!isHeadingBlock(block) &&
!isRootBlock(block)
)
return nothing;
const showPreviewIcon = this._context.showIcons$.value;
switch (block.flavour) {
case 'affine:page':
assertType<RootBlockModel>(block);
return block.props.title.length > 0
? html`<span
data-testid="outline-block-preview-title"
class="${styles.text} ${styles.subtypeStyles.title}"
>
${block.props.title$.value}
</span>`
: nothing;
case 'affine:paragraph':
assertType<ParagraphBlockModel>(block);
return this._TextBlockPreview(block);
case 'affine:list':
assertType<ListBlockModel>(block);
return this._TextBlockPreview(block);
case 'affine:bookmark':
assertType<BookmarkBlockModel>(block);
return html`
<span class="${styles.text} ${styles.textGeneral}"
>${
block.props.title || block.props.url || placeholderMap['bookmark']
}</span
>
${
showPreviewIcon
? html`<span class=${iconClass}
>${previewIconMap['bookmark']}</span
>`
: nothing
}
`;
case 'affine:code':
assertType<CodeBlockModel>(block);
return html`
<span class="${styles.text} ${styles.textGeneral}"
>${block.props.language ?? placeholderMap['code']}</span
>
${
showPreviewIcon
? html`<span class=${iconClass}>${previewIconMap['code']}</span>`
: nothing
}
`;
case 'affine:database':
assertType<DatabaseBlockModel>(block);
return html`
<span class="${styles.text} ${styles.textGeneral}"
>${
block.props.title.toString().length
? block.props.title.toString()
: placeholderMap['database']
}</span
>
${
showPreviewIcon
? html`<span class=${iconClass}>${previewIconMap['table']}</span>`
: nothing
}
`;
case 'affine:image':
assertType<ImageBlockModel>(block);
return html`
<span class="${styles.text} ${styles.textGeneral}"
>${
block.props.caption?.length
? block.props.caption
: placeholderMap['image']
}</span
>
${
showPreviewIcon
? html`<span class=${iconClass}>${previewIconMap['image']}</span>`
: nothing
}
`;
case 'affine:attachment':
assertType<AttachmentBlockModel>(block);
return html`
<span class="${styles.text} ${styles.textGeneral}"
>${
block.props.name?.length
? block.props.name
: placeholderMap['attachment']
}</span
>
${
showPreviewIcon
? html`<span class=${iconClass}
>${previewIconMap['attachment']}</span
>`
: nothing
}
`;
default:
return nothing;
}
}
@property({ attribute: false })
accessor block!: BlockModel;
@property({ attribute: false })
accessor disabledIcon = false;
@consume({ context: tocContext })
private accessor _context!: TocContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_OUTLINE_BLOCK_PREVIEW]: OutlineBlockPreview;
}
}

View File

@@ -0,0 +1,97 @@
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import {
AttachmentIcon,
BlockIcon,
BookmarkIcon,
BulletedListIcon,
CheckBoxCheckLinearIcon,
CodeBlockIcon,
DatabaseKanbanViewIcon,
DatabaseTableViewIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
ImageIcon,
NumberedListIcon,
QuoteIcon,
TextIcon,
} from '@blocksuite/icons/lit';
import type { EditorHost } from '@blocksuite/std';
import { createContext } from '@lit/context';
import type { Signal } from '@preact/signals-core';
import type { TemplateResult } from 'lit';
const _16px = { width: '16px', height: '16px' };
const paragraphIconMap: Record<
ParagraphBlockModel['props']['type'],
TemplateResult<1>
> = {
quote: QuoteIcon(_16px),
text: TextIcon(_16px),
h1: Heading1Icon(_16px),
h2: Heading2Icon(_16px),
h3: Heading3Icon(_16px),
h4: Heading4Icon(_16px),
h5: Heading5Icon(_16px),
h6: Heading6Icon(_16px),
};
export const previewIconMap = {
...paragraphIconMap,
code: CodeBlockIcon(_16px),
numbered: NumberedListIcon(_16px),
bulleted: BulletedListIcon(_16px),
todo: CheckBoxCheckLinearIcon(_16px),
toggle: BlockIcon(_16px),
bookmark: BookmarkIcon(_16px),
image: ImageIcon(_16px),
table: DatabaseTableViewIcon(_16px),
kanban: DatabaseKanbanViewIcon(_16px),
attachment: AttachmentIcon(_16px),
};
const paragraphPlaceholderMap: Record<
ParagraphBlockModel['props']['type'],
string
> = {
quote: 'Quote',
text: 'Text Block',
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
h5: 'Heading 5',
h6: 'Heading 6',
};
export const placeholderMap = {
code: 'Code Block',
bulleted: 'Bulleted List',
numbered: 'Numbered List',
toggle: 'Toggle List',
todo: 'Todo',
bookmark: 'Bookmark',
image: 'Image',
database: 'Database',
attachment: 'Attachment',
...paragraphPlaceholderMap,
};
export const headingKeys = new Set(
Object.keys(paragraphPlaceholderMap).filter(key => key.startsWith('h'))
);
export const outlineSettingsKey = 'outlinePanelSettings';
export type TocContext = {
editor$: Signal<EditorHost>;
enableSorting$: Signal<boolean>;
showIcons$: Signal<boolean>;
fitPadding$: Signal<number[]>;
};
export const tocContext = createContext<TocContext>('tocContext');

View File

@@ -0,0 +1,39 @@
import { AFFINE_OUTLINE_NOTICE, OutlineNotice } from './body/outline-notice';
import {
AFFINE_OUTLINE_PANEL_BODY,
OutlinePanelBody,
} from './body/outline-panel-body';
import { AFFINE_OUTLINE_NOTE_CARD, OutlineNoteCard } from './card/outline-card';
import {
AFFINE_OUTLINE_BLOCK_PREVIEW,
OutlineBlockPreview,
} from './card/outline-preview';
import {
AFFINE_OUTLINE_PANEL_HEADER,
OutlinePanelHeader,
} from './header/outline-panel-header';
import {
AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU,
OutlineNotePreviewSettingMenu,
} from './header/outline-setting-menu';
import {
AFFINE_MOBILE_OUTLINE_MENU,
MobileOutlineMenu,
} from './mobile-outline-panel';
import { AFFINE_OUTLINE_PANEL, OutlinePanel } from './outline-panel';
import { AFFINE_OUTLINE_VIEWER, OutlineViewer } from './outline-viewer';
export function effects() {
customElements.define(
AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU,
OutlineNotePreviewSettingMenu
);
customElements.define(AFFINE_OUTLINE_NOTICE, OutlineNotice);
customElements.define(AFFINE_OUTLINE_PANEL, OutlinePanel);
customElements.define(AFFINE_OUTLINE_PANEL_HEADER, OutlinePanelHeader);
customElements.define(AFFINE_OUTLINE_NOTE_CARD, OutlineNoteCard);
customElements.define(AFFINE_OUTLINE_VIEWER, OutlineViewer);
customElements.define(AFFINE_MOBILE_OUTLINE_MENU, MobileOutlineMenu);
customElements.define(AFFINE_OUTLINE_BLOCK_PREVIEW, OutlineBlockPreview);
customElements.define(AFFINE_OUTLINE_PANEL_BODY, OutlinePanelBody);
}

View File

@@ -0,0 +1,44 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const host = style({});
export const container = style({
display: 'flex',
width: '100%',
height: '40px',
alignItems: 'center',
justifyContent: 'space-between',
boxSizing: 'border-box',
padding: '8px 16px',
});
export const noteSettingContainer = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
});
export const label = style({
width: '119px',
height: '22px',
fontSize: '14px',
fontWeight: 500,
lineHeight: '22px',
color: cssVarV2('text/secondary'),
});
export const notePreviewSettingContainer = style({
display: 'none',
justifyContent: 'center',
alignItems: 'center',
background: cssVarV2('layer/background/overlayPanel'),
boxShadow: cssVar('shadow2'),
borderRadius: '8px',
selectors: {
'&[data-show]': {
display: 'flex',
},
},
});

View File

@@ -0,0 +1,90 @@
import { createButtonPopper } from '@blocksuite/affine-shared/utils';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { SettingsIcon, SortIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { consume } from '@lit/context';
import { signal } from '@preact/signals-core';
import { html } from 'lit';
import { query } from 'lit/decorators.js';
import { type TocContext, tocContext } from '../config';
import * as styles from './outline-panel-header.css';
export const AFFINE_OUTLINE_PANEL_HEADER = 'affine-outline-panel-header';
export class OutlinePanelHeader extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
private _notePreviewSettingMenuPopper: ReturnType<
typeof createButtonPopper
> | null = null;
private readonly _settingPopperShow$ = signal(false);
override firstUpdated() {
this._notePreviewSettingMenuPopper = createButtonPopper({
reference: this._noteSettingButton,
popperElement: this._notePreviewSettingMenu,
stateUpdated: ({ display }) => {
this._settingPopperShow$.value = display === 'show';
},
mainAxis: 14,
crossAxis: -30,
});
this.disposables.add(this._notePreviewSettingMenuPopper);
}
override render() {
const sortingEnabled = this._context.enableSorting$.value;
const showSettingPopper = this._settingPopperShow$.value;
return html`<div class=${styles.container}>
<div class=${styles.noteSettingContainer}>
<span class=${styles.label}>Table of Contents</span>
<edgeless-tool-icon-button
data-testid="toggle-toc-setting-button"
class="${showSettingPopper ? 'active' : ''}"
.tooltip=${showSettingPopper ? '' : 'Preview Settings'}
.tipPosition=${'bottom'}
.active=${showSettingPopper}
.activeMode=${'background'}
@click=${() => this._notePreviewSettingMenuPopper?.toggle()}
>
${SettingsIcon({ width: '20px', height: '20px' })}
</edgeless-tool-icon-button>
</div>
<edgeless-tool-icon-button
data-testid="toggle-notes-sorting-button"
class="${sortingEnabled ? 'active' : ''}"
.tooltip=${'Visibility and sort'}
.tipPosition=${'left'}
.iconContainerPadding=${0}
.active=${sortingEnabled}
.activeMode=${'color'}
@click=${() => {
this._context.enableSorting$.value = !sortingEnabled;
}}
>
${SortIcon({ width: '20px', height: '20px' })}
</edgeless-tool-icon-button>
</div>
<div class=${styles.notePreviewSettingContainer}>
<affine-outline-note-preview-setting-menu></affine-outline-note-preview-setting-menu>
</div>`;
}
@query(`.${styles.notePreviewSettingContainer}`)
private accessor _notePreviewSettingMenu!: HTMLDivElement;
@query(`.${styles.noteSettingContainer}`)
private accessor _noteSettingButton!: HTMLDivElement;
@consume({ context: tocContext })
private accessor _context!: TocContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_OUTLINE_PANEL_HEADER]: OutlinePanelHeader;
}
}

View File

@@ -0,0 +1,47 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const host = style({});
export const notePreviewSettingMenuContainer = style({
padding: '8px',
width: '220px',
display: 'flex',
flexDirection: 'column',
});
export const notePreviewSettingMenuItem = style({
display: 'flex',
boxSizing: 'border-box',
width: '100%',
height: '28px',
padding: '4px 12px',
alignItems: 'center',
});
export const settingLabel = style({
fontFamily: 'sans-serif',
fontSize: '12px',
fontWeight: 500,
lineHeight: '20px',
color: cssVarV2('text/secondary'),
padding: '0 4px',
});
export const action = style({
gap: '4px',
});
export const actionLabel = style({
width: '138px',
height: '20px',
padding: '0 4px',
fontSize: '12px',
fontWeight: 500,
lineHeight: '20px',
color: cssVarV2('text/primary'),
});
export const toggleButton = style({
display: 'flex',
});

View File

@@ -0,0 +1,47 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { consume } from '@lit/context';
import { html } from 'lit';
import { type TocContext, tocContext } from '../config';
import * as styles from './outline-setting-menu.css';
export const AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU =
'affine-outline-note-preview-setting-menu';
export class OutlineNotePreviewSettingMenu extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
override render() {
const showPreviewIcon = this._context.showIcons$.value;
return html`<div
class=${styles.notePreviewSettingMenuContainer}
@click=${(e: MouseEvent) => e.stopPropagation()}
>
<div class=${styles.notePreviewSettingMenuItem}>
<div class=${styles.settingLabel}>Settings</div>
</div>
<div class="${styles.notePreviewSettingMenuItem} ${styles.action}">
<div class=${styles.actionLabel}>Show type icon</div>
<div class=${styles.toggleButton}>
<toggle-switch
.on=${showPreviewIcon}
.onChange=${() => {
this._context.showIcons$.value = !showPreviewIcon;
}}
></toggle-switch>
</div>
</div>
</div>`;
}
@consume({ context: tocContext })
private accessor _context!: TocContext;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU]: OutlineNotePreviewSettingMenu;
}
}

View File

@@ -0,0 +1,3 @@
export * from './mobile-outline-panel.js';
export * from './outline-panel.js';
export * from './outline-viewer.js';

View File

@@ -0,0 +1,197 @@
import {
NoteDisplayMode,
ParagraphBlockModel,
RootBlockModel,
} from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
type EditorHost,
PropTypes,
requiredProperties,
} from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { getHeadingBlocksFromDoc } from './utils/query.js';
import {
observeActiveHeadingDuringScroll,
scrollToBlockWithHighlight,
} from './utils/scroll.js';
export const AFFINE_MOBILE_OUTLINE_MENU = 'affine-mobile-outline-menu';
@requiredProperties({
editor: PropTypes.object,
})
export class MobileOutlineMenu extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = css`
:host {
position: relative;
display: flex;
max-height: 100%;
box-sizing: border-box;
flex-direction: column;
align-items: flex-start;
padding: 0 12px;
}
:host::-webkit-scrollbar {
display: none;
}
.outline-menu-item {
display: inline;
align-items: center;
align-self: stretch;
padding: 11px 8px;
overflow: hidden;
color: ${unsafeCSSVarV2('text/primary')};
text-overflow: ellipsis;
/* Body/Regular */
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 17px;
font-style: normal;
font-weight: 400;
line-height: 22px;
letter-spacing: -0.43px;
white-space: nowrap;
}
.outline-menu-item.active {
color: ${unsafeCSSVarV2('text/emphasis')};
}
.outline-menu-item:active {
background: var(--affine-hover-color);
}
.outline-menu-item.title,
.outline-menu-item.h1 {
padding-left: 8px;
}
.outline-menu-item.h2 {
padding-left: 28px;
}
.outline-menu-item.h3 {
padding-left: 48px;
}
.outline-menu-item.h4 {
padding-left: 68px;
}
.outline-menu-item.h5 {
padding-left: 88px;
}
.outline-menu-item.h6 {
padding-left: 108px;
}
`;
private readonly _activeHeadingId$ = signal<string | null>(null);
private _highlightMaskDisposable = () => {};
private _lockActiveHeadingId = false;
private async _scrollToBlock(blockId: string) {
this._lockActiveHeadingId = true;
this._activeHeadingId$.value = blockId;
this._highlightMaskDisposable = await scrollToBlockWithHighlight(
this.editor,
blockId
);
this._lockActiveHeadingId = false;
}
override connectedCallback() {
super.connectedCallback();
this.disposables.add(
observeActiveHeadingDuringScroll(
() => this.editor,
newHeadingId => {
if (this._lockActiveHeadingId) return;
this._activeHeadingId$.value = newHeadingId;
}
)
);
}
override disconnectedCallback() {
super.disconnectedCallback();
this._highlightMaskDisposable();
}
renderItem = (item: BlockModel) => {
let className = '';
let text = '';
if (matchModels(item, [RootBlockModel])) {
className = 'title';
text = item.props.title$.value.toString();
} else if (
matchModels(item, [ParagraphBlockModel]) &&
['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(item.props.type$.value)
) {
className = item.props.type$.value;
text = item.props.text$.value.toString();
} else {
return nothing;
}
return html`<div
class=${classMap({
'outline-menu-item': true,
[className]: true,
active: this._activeHeadingId$.value === item.id,
})}
@click=${() => {
this._scrollToBlock(item.id).catch(console.error);
}}
>
${text}
</div>`;
};
override render() {
const docModeService = this.editor.std.get(DocModeProvider);
const mode = docModeService.getEditorMode();
if (this.editor.store.root === null || mode === 'edgeless') return nothing;
const headingBlocks = getHeadingBlocksFromDoc(
this.editor.store,
[NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocOnly],
true
);
if (headingBlocks.length === 0) return nothing;
const items = [
...(this.editor.store.meta?.title !== '' ? [this.editor.store.root] : []),
...headingBlocks,
];
return repeat(items, block => block.id, this.renderItem);
}
@property({ attribute: false })
accessor editor!: EditorHost;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_MOBILE_OUTLINE_MENU]: MobileOutlineMenu;
}
}

View File

@@ -0,0 +1,15 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const outlinePanel = style({
display: 'flex',
flexDirection: 'column',
backgroundColor: cssVarV2('layer/background/primary'),
boxSizing: 'border-box',
width: '100%',
height: '100%',
fontFamily: cssVar('fontSansFamily'),
paddingTop: '8px',
position: 'relative',
});

View File

@@ -0,0 +1,118 @@
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
type EditorHost,
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/std';
import { provide } from '@lit/context';
import { effect, signal } from '@preact/signals-core';
import { html, type PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
import { outlineSettingsKey, type TocContext, tocContext } from './config.js';
import * as styles from './outline-panel.css';
export const AFFINE_OUTLINE_PANEL = 'affine-outline-panel';
@requiredProperties({
editor: PropTypes.object,
})
export class OutlinePanel extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
private _getEditorMode(host: EditorHost) {
const docModeService = host.std.get(DocModeProvider);
const mode = docModeService.getEditorMode();
return mode;
}
private _setContext() {
this._context = {
editor$: signal(this.editor),
showIcons$: signal<boolean>(false),
enableSorting$: signal<boolean>(false),
fitPadding$: signal<number[]>(this.fitPadding),
};
this.disposables.add(
effect(() => {
const settingsString = localStorage.getItem(outlineSettingsKey);
const settings = settingsString ? JSON.parse(settingsString) : null;
if (settings) {
this._context.showIcons$.value = settings.showIcons;
}
const editor = this._context.editor$.value;
if (this._getEditorMode(editor) === 'edgeless') {
this._context.enableSorting$.value = true;
} else if (settings) {
this._context.enableSorting$.value = settings.enableSorting;
}
})
);
}
private _watchSettingsChange() {
this.disposables.add(
effect(() => {
if (this._getEditorMode(this._context.editor$.value) === 'edgeless')
return;
const showPreviewIcon = this._context.showIcons$.value;
const enableNotesSorting = this._context.enableSorting$.value;
localStorage.setItem(
outlineSettingsKey,
JSON.stringify({
showIcons: showPreviewIcon,
enableSorting: enableNotesSorting,
})
);
})
);
}
override connectedCallback() {
super.connectedCallback();
this.classList.add(styles.outlinePanel);
this._setContext();
this._watchSettingsChange();
}
override willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('editor')) {
this._context.editor$.value = this.editor;
}
if (changedProperties.has('fitPadding')) {
this._context.fitPadding$.value = this.fitPadding;
}
}
override render() {
if (!this.editor) return;
return html`
<affine-outline-panel-header></affine-outline-panel-header>
<affine-outline-panel-body> </affine-outline-panel-body>
<affine-outline-notice></affine-outline-notice>
`;
}
@provide({ context: tocContext })
private accessor _context!: TocContext;
@property({ attribute: false })
accessor editor!: EditorHost;
@property({ attribute: false })
accessor fitPadding!: number[];
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_OUTLINE_PANEL]: OutlinePanel;
}
}

View File

@@ -0,0 +1,325 @@
import { NoteDisplayMode } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { TocIcon } from '@blocksuite/icons/lit';
import {
type EditorHost,
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/std';
import { provide } from '@lit/context';
import { signal } from '@preact/signals-core';
import { css, html, nothing, type PropertyValues } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { type TocContext, tocContext } from './config.js';
import { getHeadingBlocksFromDoc } from './utils/query.js';
import {
observeActiveHeadingDuringScroll,
scrollToBlockWithHighlight,
} from './utils/scroll.js';
export const AFFINE_OUTLINE_VIEWER = 'affine-outline-viewer';
@requiredProperties({
editor: PropTypes.object,
})
export class OutlineViewer extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
affine-outline-viewer {
display: flex;
}
.outline-viewer-root {
--duration: 120ms;
--timing: cubic-bezier(0.42, 0, 0.58, 1);
max-height: 100%;
box-sizing: border-box;
display: flex;
}
.outline-viewer-indicators-container {
position: absolute;
top: 0;
right: 0;
max-height: 100%;
display: flex;
flex-direction: column;
align-items: flex-end;
overflow-y: hidden;
}
.outline-viewer-indicator-wrapper {
flex: 1 1 16px;
display: flex;
align-items: center;
justify-content: center;
}
.outline-viewer-indicator {
width: 20px;
height: 2px;
border-radius: 1px;
overflow: hidden;
background: var(--affine-black-10, rgba(0, 0, 0, 0.1));
}
.outline-viewer-indicator.active {
width: 24px;
background: var(--affine-text-primary-color);
}
.outline-viewer-panel {
position: relative;
display: flex;
width: 0px;
max-height: 100%;
box-sizing: border-box;
flex-direction: column;
align-items: flex-start;
border-radius: 8px;
border-width: 0px;
border-style: solid;
border-color: var(--affine-border-color);
background: var(--affine-background-overlay-panel-color);
box-shadow: 0px 6px 16px 0px rgba(0, 0, 0, 0.14);
overflow-y: auto;
opacity: 0;
transform: translateX(0px);
transition:
width 0s var(--duration),
padding 0s var(--duration),
border-width 0s var(--duration),
transform var(--duration) var(--timing),
opacity var(--duration) var(--timing);
}
${scrollbarStyle('.outline-viewer-panel')}
.outline-viewer-header {
display: flex;
padding: 6px;
align-items: center;
gap: 4px;
align-self: stretch;
span {
flex: 1;
overflow: hidden;
color: var(--affine-text-secondary-color);
text-overflow: ellipsis;
font-family: var(--affine-font-family);
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px;
}
}
.outline-viewer-item {
width: 100%;
}
.outline-viewer-root:hover {
.outline-viewer-indicators-container {
visibility: hidden;
}
.outline-viewer-panel {
width: 200px;
border-width: 1px;
padding: 8px 4px 8px 8px;
opacity: 1;
transform: translateX(-10px);
transition:
transform var(--duration) var(--timing),
opacity var(--duration) var(--timing);
}
}
`;
private readonly _activeHeadingId$ = signal<string | null>(null);
private _highlightMaskDisposable = () => {};
private _lockActiveHeadingId = false;
private readonly _scrollPanel = () => {
this._activeItem?.scrollIntoView({
behavior: 'instant',
block: 'center',
});
};
private async _scrollToBlock(blockId: string) {
this._lockActiveHeadingId = true;
this._activeHeadingId$.value = blockId;
this._highlightMaskDisposable = await scrollToBlockWithHighlight(
this.editor,
blockId
);
this._lockActiveHeadingId = false;
}
private _toggleOutlinePanel() {
if (this.toggleOutlinePanel) {
this._showViewer = false;
this.toggleOutlinePanel();
}
}
private _setContext() {
this._context = {
editor$: signal(this.editor),
showIcons$: signal<boolean>(false),
enableSorting$: signal<boolean>(false),
fitPadding$: signal<number[]>([]),
};
}
override connectedCallback() {
super.connectedCallback();
this.disposables.add(
observeActiveHeadingDuringScroll(
() => this.editor,
newHeadingId => {
if (this._lockActiveHeadingId) return;
this._activeHeadingId$.value = newHeadingId;
}
)
);
// title update
this.disposables.add(
this.editor.store.workspace.meta.docMetaUpdated.subscribe(() => {
this.requestUpdate();
})
);
this._setContext();
}
override willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('editor')) {
this._context.editor$.value = this.editor;
}
}
override disconnectedCallback() {
super.disconnectedCallback();
this._highlightMaskDisposable();
}
override render() {
const docModeService = this.editor.std.get(DocModeProvider);
const mode = docModeService.getEditorMode();
if (this.editor.store.root === null || mode === 'edgeless') return nothing;
const headingBlocks = getHeadingBlocksFromDoc(
this.editor.store,
[NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocOnly],
true
);
if (headingBlocks.length === 0) return nothing;
const items = [
...(this.editor.store.meta?.title !== '' ? [this.editor.store.root] : []),
...headingBlocks,
];
const toggleOutlinePanelButton =
this.toggleOutlinePanel !== null
? html`<edgeless-tool-icon-button
.tooltip=${'Open in sidebar'}
.tipPosition=${'top-end'}
.activeMode=${'background'}
@click=${this._toggleOutlinePanel}
data-testid="toggle-outline-panel-button"
>
${TocIcon({ width: '1em', height: '1em' })}
</edgeless-tool-icon-button>`
: nothing;
return html`
<div class="outline-viewer-root" @mouseenter=${this._scrollPanel}>
<div class="outline-viewer-indicators-container">
${repeat(
items,
block => block.id,
block =>
html`<div class="outline-viewer-indicator-wrapper">
<div
class=${classMap({
'outline-viewer-indicator': true,
active: this._activeHeadingId$.value === block.id,
})}
></div>
</div>`
)}
</div>
<div class="outline-viewer-panel">
<div class="outline-viewer-item outline-viewer-header">
<span>Table of Contents</span>
${toggleOutlinePanelButton}
</div>
${repeat(
items,
block => block.id,
block => {
return html`<div
class=${classMap({
'outline-viewer-item': true,
active: this._activeHeadingId$.value === block.id,
})}
>
<affine-outline-block-preview
class=${classMap({
active: this._activeHeadingId$.value === block.id,
})}
.block=${block}
@click=${() => {
this._scrollToBlock(block.id).catch(console.error);
}}
>
</affine-outline-block-preview>
</div>`;
}
)}
</div>
</div>
`;
}
@provide({ context: tocContext })
private accessor _context!: TocContext;
@query('.outline-viewer-item.active')
private accessor _activeItem: HTMLElement | null = null;
@state()
private accessor _showViewer: boolean = false;
@property({ attribute: false })
accessor editor!: EditorHost;
@property({ attribute: false })
accessor toggleOutlinePanel: (() => void) | null = null;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_OUTLINE_VIEWER]: OutlineViewer;
}
}

View File

@@ -0,0 +1,27 @@
import type { NoteBlockModel, NoteDisplayMode } from '@blocksuite/affine-model';
export type ReorderEvent = CustomEvent<{
currentNumber: number;
targetNumber: number;
realIndex: number;
}>;
export type SelectEvent = CustomEvent<{
id: string;
selected: boolean;
number: number;
multiselect: boolean;
}>;
export type FitViewEvent = CustomEvent<{
block: NoteBlockModel;
}>;
export type ClickBlockEvent = CustomEvent<{
blockId: string;
}>;
export type DisplayModeChangeEvent = CustomEvent<{
note: NoteBlockModel;
newMode: NoteDisplayMode;
}>;

View File

@@ -0,0 +1,8 @@
export type NoteCardEntity = {
type: 'toc-card';
noteId: string;
};
export type NoteDropPayload = {
noteId: string;
};

View File

@@ -0,0 +1,72 @@
import {
NoteBlockModel,
NoteDisplayMode,
ParagraphBlockModel,
RootBlockModel,
} from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockModel, Store } from '@blocksuite/store';
import { headingKeys } from '../config.js';
export function getNotesFromStore(
store: Store,
modes: NoteDisplayMode[] = [
NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly,
NoteDisplayMode.EdgelessOnly,
]
) {
const rootModel = store.root;
if (!rootModel) return [];
const notes: NoteBlockModel[] = [];
rootModel.children.forEach(block => {
if (!matchModels(block, [NoteBlockModel])) return;
if (modes.includes(block.props.displayMode$.value)) {
notes.push(block);
}
});
return notes;
}
export function isRootBlock(block: BlockModel): block is RootBlockModel {
return matchModels(block, [RootBlockModel]);
}
export function isHeadingBlock(
block: BlockModel
): block is ParagraphBlockModel {
return (
matchModels(block, [ParagraphBlockModel]) &&
headingKeys.has(block.props.type$.value)
);
}
export function getHeadingBlocksFromNote(
note: NoteBlockModel,
ignoreEmpty = false
) {
const models = note.children.filter(block => {
const empty = block.text && block.text.length > 0;
return isHeadingBlock(block) && (!ignoreEmpty || empty);
});
return models;
}
export function getHeadingBlocksFromDoc(
store: Store,
modes: NoteDisplayMode[] = [
NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly,
NoteDisplayMode.EdgelessOnly,
],
ignoreEmpty = false
) {
const notes = getNotesFromStore(store, modes);
return notes.flatMap(note => getHeadingBlocksFromNote(note, ignoreEmpty));
}

View File

@@ -0,0 +1,201 @@
import { getDocTitleByEditorHost } from '@blocksuite/affine-fragment-doc-title';
import { NoteDisplayMode } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import type { Viewport } from '@blocksuite/affine-shared/types';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { clamp } from '@blocksuite/global/gfx';
import type { EditorHost } from '@blocksuite/std';
import { getHeadingBlocksFromDoc } from './query.js';
export function scrollToBlock(host: EditorHost, blockId: string) {
const docModeService = host.std.get(DocModeProvider);
const mode = docModeService.getEditorMode();
if (mode === 'edgeless') return;
if (host.store.root?.id === blockId) {
const docTitle = getDocTitleByEditorHost(host);
if (!docTitle) return;
docTitle.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
} else {
const block = host.view.getBlock(blockId);
if (!block) return;
block.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}
export function isBlockBeforeViewportCenter(
blockId: string,
editorHost: EditorHost
) {
const block = editorHost.view.getBlock(blockId);
if (!block) return false;
const editorRect = (
editorHost.parentElement ?? editorHost
).getBoundingClientRect();
const blockRect = block.getBoundingClientRect();
const editorCenter =
clamp(editorRect.top, 0, document.documentElement.clientHeight) +
Math.min(editorRect.height, document.documentElement.clientHeight) / 2;
const blockCenter = blockRect.top + blockRect.height / 2;
return blockCenter < editorCenter + blockRect.height;
}
export const observeActiveHeadingDuringScroll = (
getEditor: () => EditorHost, // workaround for editor changed
update: (activeHeading: string | null) => void
) => {
const handler = () => {
const host = getEditor();
const headings = getHeadingBlocksFromDoc(
host.store,
[NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocOnly],
true
);
let activeHeadingId = host.store.root?.id ?? null;
headings.forEach(heading => {
if (isBlockBeforeViewportCenter(heading.id, host)) {
activeHeadingId = heading.id;
}
});
update(activeHeadingId);
};
handler();
const disposables = new DisposableGroup();
disposables.addFromEvent(window, 'scroll', handler, true);
return disposables;
};
let highlightMask: HTMLDivElement | null = null;
let highlightTimeoutId: ReturnType<typeof setTimeout> | null = null;
function highlightBlock(host: EditorHost, blockId: string) {
const emptyClear = () => {};
if (host.store.root?.id === blockId) return emptyClear;
const rootComponent = host.querySelector<
HTMLElement & { viewport: Viewport }
>('affine-page-root');
if (!rootComponent) return emptyClear;
if (!rootComponent.viewport) {
console.error('viewport should exist');
return emptyClear;
}
const {
top: offsetY,
left: offsetX,
scrollTop,
scrollLeft,
} = rootComponent.viewport;
const block = host.view.getBlock(blockId);
if (!block) return emptyClear;
const blockRect = block.getBoundingClientRect();
const { top, left, width, height } = blockRect;
if (!highlightMask) {
highlightMask = document.createElement('div');
rootComponent.append(highlightMask);
}
Object.assign(highlightMask.style, {
position: 'absolute',
top: `${top - offsetY + scrollTop}px`,
left: `${left - offsetX + scrollLeft}px`,
width: `${width}px`,
height: `${height}px`,
background: 'var(--affine-hover-color)',
borderRadius: '4px',
display: 'block',
});
// Clear the previous timeout if it exists
if (highlightTimeoutId !== null) {
clearTimeout(highlightTimeoutId);
}
highlightTimeoutId = setTimeout(() => {
if (highlightMask) {
highlightMask.style.display = 'none';
}
}, 1000);
return () => {
if (highlightMask !== null) {
highlightMask.remove();
highlightMask = null;
}
if (highlightTimeoutId !== null) {
clearTimeout(highlightTimeoutId);
highlightTimeoutId = null;
}
};
}
// this function is useful when the scroll need smooth animation
let highlightIntervalId: ReturnType<typeof setInterval> | null = null;
export async function scrollToBlockWithHighlight(
host: EditorHost,
blockId: string,
timeout = 3000
) {
scrollToBlock(host, blockId);
let timeCount = 0;
return new Promise<ReturnType<typeof highlightBlock>>(resolve => {
if (highlightIntervalId !== null) {
clearInterval(highlightIntervalId);
}
// wait block be scrolled into view
let lastTop = -1;
highlightIntervalId = setInterval(() => {
if (highlightIntervalId === null) {
console.error('unreachable code');
return;
}
const block = host.view.getBlock(blockId);
if (!block || timeCount > timeout) {
clearInterval(highlightIntervalId);
resolve(() => {});
return;
}
const blockRect = block.getBoundingClientRect();
const { top } = blockRect;
if (top !== lastTop) {
timeCount += 100;
lastTop = top;
return;
}
clearInterval(highlightIntervalId);
// highlight block
resolve(highlightBlock(host, blockId));
}, 100);
});
}

View File

@@ -0,0 +1,12 @@
import { ViewExtensionProvider } from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
export class OutlineViewExtension extends ViewExtensionProvider {
override name = 'affine-outline-fragment';
override effect() {
super.effect();
effects();
}
}

View File

@@ -0,0 +1,19 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/note" },
{ "path": "../../ext-loader" },
{ "path": "../doc-title" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}