feat(editor): add experimental feature adapter panel to AFFiNE canary (#12489)

Closes: [BS-2539](https://linear.app/affine-design/issue/BS-2539/为-affine-添加-ef,并且支持在-affine-预览对应的功能)

> [!warning]
> This feature is only available in the canary build and is intended for debugging purposes.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced an "Adapter Panel" feature with a new sidebar tab for previewing document content in multiple formats (Markdown, PlainText, HTML, Snapshot), controllable via a feature flag.
  - Added a fully integrated adapter panel component with reactive UI elements for selecting adapters, toggling HTML preview modes, and updating content.
  - Provided a customizable adapter panel for both main app and playground environments, supporting content transformation pipelines and export previews.
  - Enabled seamless toggling and live updating of adapter panel content through intuitive menus and controls.

- **Localization**
  - Added English translations and descriptive settings for the Adapter Panel feature.

- **Chores**
  - Added new package and workspace dependencies along with TypeScript project references to support the Adapter Panel modules and components.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
donteatfriedrice
2025-05-23 14:08:12 +00:00
parent 2a80fbb993
commit a828c74f87
28 changed files with 970 additions and 308 deletions

View File

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

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,216 @@
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,18 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}