donteatfriedrice a828c74f87
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 -->
2025-05-23 14:08:12 +00:00

178 lines
4.9 KiB
TypeScript

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;
}
}