chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
+3
View File
@@ -0,0 +1,3 @@
# BlockSuite Playground Apps
This directory contains application entries used for the [BlockSuite playground](https://try-blocksuite.vercel.app) online site. They serve as comprehensive examples utilizing the full capabilities of BlockSuite, and also act as the entry point for executing E2E test cases.
@@ -0,0 +1,285 @@
/* eslint-disable @typescript-eslint/no-restricted-imports */
import '@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.js';
import { ShadowlessElement } from '@blocksuite/block-std';
import {
defaultImageProxyMiddleware,
docLinkBaseURLMiddlewareBuilder,
embedSyncedDocMiddleware,
type HtmlAdapter,
HtmlAdapterFactoryIdentifier,
type MarkdownAdapter,
MarkdownAdapterFactoryIdentifier,
type PlainTextAdapter,
PlainTextAdapterFactoryIdentifier,
titleMiddleware,
} from '@blocksuite/blocks';
import { WithDisposable } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import { type DocSnapshot, Job } from '@blocksuite/store';
import { effect } from '@preact/signals-core';
import type SlTabPanel from '@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.js';
import { css, html, type PropertyValues } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
@customElement('adapters-panel')
export class AdaptersPanel extends WithDisposable(ShadowlessElement) {
static override styles = css`
adapters-panel {
width: 36vw;
}
.adapters-container {
border: 1px solid var(--affine-border-color, #e3e2e4);
background-color: var(--affine-background-primary-color);
box-sizing: border-box;
position: relative;
}
.adapter-container {
padding: 0px 16px;
width: 100%;
height: calc(100vh - 80px);
white-space: pre-wrap;
color: var(--affine-text-primary-color);
overflow: auto;
}
.update-button {
position: absolute;
top: 8px;
right: 12px;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--affine-border-color);
font-family: var(--affine-font-family);
color: var(--affine-text-primary-color);
background-color: var(--affine-background-primary-color);
}
.update-button:hover {
background-color: var(--affine-hover-color);
}
.html-panel {
display: flex;
gap: 8px;
flex-direction: column;
}
.html-preview-container,
.html-panel-content {
width: 100%;
flex: 1;
border: none;
box-sizing: border-box;
color: var(--affine-text-primary-color);
overflow: auto;
}
.html-panel-footer {
width: 100%;
height: 32px;
display: flex;
justify-content: flex-end;
span {
cursor: pointer;
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--affine-border-color);
font-family: var(--affine-font-family);
color: var(--affine-text-primary-color);
background-color: var(--affine-background-primary-color);
line-height: 20px;
}
span[active] {
background-color: var(--affine-hover-color);
}
}
`;
get doc() {
return this.editor.doc;
}
private _createJob() {
return new Job({
collection: this.doc.collection,
middlewares: [
docLinkBaseURLMiddlewareBuilder('https://example.com').get(),
titleMiddleware,
embedSyncedDocMiddleware('content'),
defaultImageProxyMiddleware,
],
});
}
private _getDocSnapshot() {
const job = this._createJob();
const result = job.docToSnapshot(this.doc);
return result;
}
private async _getHtmlContent() {
const job = this._createJob();
const htmlAdapterFactory = this.editor.std.provider.get(
HtmlAdapterFactoryIdentifier
);
const htmlAdapter = htmlAdapterFactory.get(job) as HtmlAdapter;
const result = await htmlAdapter.fromDoc(this.doc);
return result?.file;
}
private async _getMarkdownContent() {
const job = this._createJob();
const markdownAdapterFactory = this.editor.std.provider.get(
MarkdownAdapterFactoryIdentifier
);
const markdownAdapter = markdownAdapterFactory.get(job) as MarkdownAdapter;
const result = await markdownAdapter.fromDoc(this.doc);
return result?.file;
}
private async _getPlainTextContent() {
const job = this._createJob();
const plainTextAdapterFactory = this.editor.std.provider.get(
PlainTextAdapterFactoryIdentifier
);
const plainTextAdapter = plainTextAdapterFactory.get(
job
) as PlainTextAdapter;
const result = await plainTextAdapter.fromDoc(this.doc);
return result?.file;
}
private async _handleTabShow(name: string) {
switch (name) {
case 'markdown':
this._markdownContent = (await this._getMarkdownContent()) || '';
break;
case 'html':
this._htmlContent = (await this._getHtmlContent()) || '';
break;
case 'plaintext':
this._plainTextContent = (await this._getPlainTextContent()) || '';
break;
case 'snapshot':
this._docSnapshot = this._getDocSnapshot() || null;
break;
}
}
private _renderHtmlPanel() {
return html`
${this._isHtmlPreview
? html`<iframe
class="html-preview-container"
.srcdoc=${this._htmlContent}
></iframe>`
: html`<div class="html-panel-content">${this._htmlContent}</div>`}
<div class="html-panel-footer">
<span
class="html-panel-footer-item"
?active=${!this._isHtmlPreview}
@click=${() => (this._isHtmlPreview = false)}
>Source</span
>
<span
class="html-panel-footer-item"
?active=${this._isHtmlPreview}
@click=${() => (this._isHtmlPreview = true)}
>Preview</span
>
</div>
`;
}
private async _updateActiveTabContent() {
if (!this._activeTab) return;
const activeTabName = this._activeTab.name;
await this._handleTabShow(activeTabName);
}
override firstUpdated() {
this.disposables.add(
effect(() => {
const doc = this.doc;
if (doc) {
this._updateActiveTabContent().catch(console.error);
}
})
);
}
override render() {
const snapshotString = this._docSnapshot
? JSON.stringify(this._docSnapshot, null, 4)
: '';
return html`
<div class="adapters-container">
<sl-tab-group
activation="auto"
@sl-tab-show=${(e: CustomEvent) => this._handleTabShow(e.detail.name)}
>
<sl-tab slot="nav" panel="markdown">Markdown</sl-tab>
<sl-tab slot="nav" panel="plaintext">PlainText</sl-tab>
<sl-tab slot="nav" panel="html">HTML</sl-tab>
<sl-tab slot="nav" panel="snapshot">Snapshot</sl-tab>
<sl-tab-panel name="markdown">
<div class="adapter-container">${this._markdownContent}</div>
</sl-tab-panel>
<sl-tab-panel name="html">
<div class="adapter-container html-panel">
${this._renderHtmlPanel()}
</div>
</sl-tab-panel>
<sl-tab-panel name="plaintext">
<div class="adapter-container">${this._plainTextContent}</div>
</sl-tab-panel>
<sl-tab-panel name="snapshot">
<div class="adapter-container">${snapshotString}</div>
</sl-tab-panel>
</sl-tab-group>
<sl-tooltip content="Update Adapter Content" placement="left" hoist>
<div class="update-button" @click="${this._updateActiveTabContent}">
Update
</div>
</sl-tooltip>
</div>
`;
}
override willUpdate(_changedProperties: PropertyValues) {
if (_changedProperties.has('editor')) {
requestIdleCallback(() => {
this._updateActiveTabContent().catch(console.error);
});
}
}
@query('sl-tab-panel[active]')
private accessor _activeTab!: SlTabPanel;
@state()
private accessor _docSnapshot: DocSnapshot | null = null;
@state()
private accessor _htmlContent = '';
@state()
private accessor _isHtmlPreview = false;
@state()
private accessor _markdownContent = '';
@state()
private accessor _plainTextContent = '';
@property({ attribute: false })
accessor editor!: AffineEditorContainer;
}
declare global {
interface HTMLElementTagNameMap {
'adapters-panel': AdaptersPanel;
}
}
@@ -0,0 +1,323 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { AttachmentBlockModel } from '@blocksuite/affine-model';
import { humanFileSize } from '@blocksuite/affine-shared/utils';
import { getAttachmentFileIcons } from '@blocksuite/blocks';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import {
ArrowDownBigIcon,
ArrowUpBigIcon,
CloseIcon,
} from '@blocksuite/icons/lit';
import { signal } from '@preact/signals-core';
import { css, html, LitElement, type TemplateResult } from 'lit';
import { customElement, query } from 'lit/decorators.js';
import type { DocInfo, MessageData, MessageDataType } from './pdf/types.js';
import { MessageOp, RenderKind, State } from './pdf/types.js';
const DPI = window.devicePixelRatio;
type FileInfo = {
name: string;
size: string;
isPDF: boolean;
icon: TemplateResult;
};
@customElement('attachment-viewer-panel')
export class AttachmentViewerPanel extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = css`
:host {
dialog {
padding: 0;
top: 50px;
border: 1px solid var(--affine-border-color);
border-radius: 8px;
background: var(--affine-v2-dialog-background-primary);
box-shadow: var(--affine-overlay-shadow);
outline: none;
}
.dialog {
position: relative;
display: flex;
flex-direction: column;
width: 700px;
height: 900px;
margin: 0 auto;
overflow: hidden;
& > .close {
user-select: none;
outline: none;
position: absolute;
right: 10px;
top: 10px;
border: none;
background: transparent;
z-index: 1;
}
header,
footer {
padding: 10px 20px;
}
footer {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--affine-text-secondary-color);
}
h5 {
display: flex;
align-items: center;
gap: 15px;
margin: 0;
.file-icon svg {
width: 20px;
height: 20px;
}
}
.body {
display: flex;
flex: 1;
align-items: center;
overflow-y: auto;
.page {
width: calc(100% - 40px);
height: auto;
margin: 0 auto;
}
.error {
margin: 0 auto;
}
}
}
.controls {
position: absolute;
bottom: 50px;
right: 20px;
}
}
`;
#cursor = signal<number>(0);
#docInfo = signal<DocInfo | null>(null);
#fileInfo = signal<FileInfo | null>(null);
#state = signal<State>(State.Connecting);
#worker: Worker | null = null;
clear = () => {
this.#dialog.close();
this.#state.value = State.IDLE;
this.#worker?.terminate();
this.#worker = null;
this.#fileInfo.value = null;
this.#docInfo.value = null;
this.#cursor.value = 0;
const canvas = this.#page;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
goto(at: number) {
this.#cursor.value = at;
this.post(MessageOp.Render, {
index: at,
scale: 1 * DPI,
kind: RenderKind.Page,
});
}
open(model: AttachmentBlockModel) {
this.#dialog.showModal();
const { name, size } = model;
const fileType = name.split('.').pop() ?? '';
const icon = getAttachmentFileIcons(fileType);
const isPDF = fileType === 'pdf';
this.#fileInfo.value = {
name,
icon,
isPDF,
size: humanFileSize(size),
};
if (!isPDF) return;
if (!model.sourceId) return;
if (this.#worker) return;
const process = async ({ data }: MessageEvent<MessageData>) => {
const { type } = data;
switch (type) {
case MessageOp.Init: {
console.debug('connecting');
this.#state.value = State.Connecting;
break;
}
case MessageOp.Inited: {
console.debug('connected');
this.#state.value = State.Connected;
const blob = await model.doc.blobSync.get(model.sourceId!);
if (!blob) return;
const buffer = await blob.arrayBuffer();
this.post(MessageOp.Open, buffer, [buffer]);
break;
}
case MessageOp.Opened: {
const info = data[type];
this.#cursor.value = 0;
this.#docInfo.value = info;
this.#state.value = State.Opened;
this.post(MessageOp.Render, {
index: 0,
scale: 1 * DPI,
kind: RenderKind.Page,
});
break;
}
case MessageOp.Rendered: {
const { index, kind, imageData } = data[type];
if (index !== this.#cursor.value) return;
const canvas = this.#page;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
console.debug('render page', index, kind);
canvas.width = imageData.width;
canvas.height = imageData.height;
ctx.clearRect(0, 0, imageData.width, imageData.height);
ctx.putImageData(imageData, 0, 0);
break;
}
}
};
this.#worker = new Worker(new URL('./pdf/worker.ts', import.meta.url), {
type: 'module',
});
this.#worker.addEventListener('message', event => {
process(event).catch(console.error);
});
}
post<T extends MessageOp>(
type: T,
data?: MessageDataType[T],
transfers?: Transferable[]
) {
if (!this.#worker) return;
const message = { type, [type]: data };
if (transfers?.length) {
this.#worker?.postMessage(message, transfers);
return;
}
this.#worker?.postMessage(message);
}
override render() {
const fileInfo = this.#fileInfo.value;
const isPDF = fileInfo?.isPDF ?? false;
const docInfo = this.#docInfo.value;
const cursor = this.#cursor.value;
const total = docInfo ? docInfo.total : 0;
const width = docInfo ? docInfo.width : 0;
const height = docInfo ? docInfo.height : 0;
const isEmpty = total === 0;
const print = (n: number) => (isEmpty ? '-' : n);
return html`
<dialog>
<div class="dialog">
<header>
<h5>
<span>${fileInfo?.name}</span>
<span>${fileInfo?.size}</span>
<span class="file-icon">${fileInfo?.icon}</span>
</h5>
</header>
<main class="body">
${isPDF
? html`<canvas class="page"></canvas>`
: html`<p class="error">This file format is not supported.</p>`}
<div class="controls">
<icon-button
.disabled=${isEmpty || cursor === 0}
@click=${() => this.goto(cursor - 1)}
>${ArrowUpBigIcon()}</icon-button
>
<icon-button
.disabled=${isEmpty || cursor + 1 === total}
@click=${() => this.goto(cursor + 1)}
>${ArrowDownBigIcon()}</icon-button
>
</div>
</main>
<footer>
<div>
<span>${print(width)}</span>
x
<span>${print(height)}</span>
</div>
<div>
<span>${print(cursor + 1)}</span>
/
<span>${print(total)}</span>
</div>
</footer>
<icon-button class="close" @click=${this.clear}
>${CloseIcon()}</icon-button
>
</div>
</dialog>
`;
}
@query('dialog')
accessor #dialog!: HTMLDialogElement;
@query('.page')
accessor #page: HTMLCanvasElement | null = null;
}
declare global {
interface HTMLElementTagNameMap {
'attachment-viewer-panel': AttachmentViewerPanel;
}
}
@@ -0,0 +1,633 @@
/* eslint-disable @typescript-eslint/no-restricted-imports */
import '@shoelace-style/shoelace/dist/components/alert/alert.js';
import '@shoelace-style/shoelace/dist/components/button/button.js';
import '@shoelace-style/shoelace/dist/components/button-group/button-group.js';
import '@shoelace-style/shoelace/dist/components/color-picker/color-picker.js';
import '@shoelace-style/shoelace/dist/components/divider/divider.js';
import '@shoelace-style/shoelace/dist/components/dropdown/dropdown.js';
import '@shoelace-style/shoelace/dist/components/icon/icon.js';
import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js';
import '@shoelace-style/shoelace/dist/components/input/input.js';
import '@shoelace-style/shoelace/dist/components/menu/menu.js';
import '@shoelace-style/shoelace/dist/components/menu-item/menu-item.js';
import '@shoelace-style/shoelace/dist/components/select/select.js';
import '@shoelace-style/shoelace/dist/components/tab/tab.js';
import '@shoelace-style/shoelace/dist/components/tab-group/tab-group.js';
import '@shoelace-style/shoelace/dist/components/tooltip/tooltip.js';
import '@shoelace-style/shoelace/dist/themes/light.css';
import '@shoelace-style/shoelace/dist/themes/dark.css';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { ShadowlessElement } from '@blocksuite/block-std';
import {
ColorScheme,
type DocMode,
DocModeProvider,
EdgelessRootService,
ExportManager,
printToPdf,
} from '@blocksuite/blocks';
import { type SerializedXYWH, SignalWatcher } from '@blocksuite/global/utils';
import type { DeltaInsert } from '@blocksuite/inline';
import type { AffineEditorContainer } from '@blocksuite/presets';
import { type DocCollection, Text } from '@blocksuite/store';
import { setBasePath } from '@shoelace-style/shoelace/dist/utilities/base-path.js';
import { css, html, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { notify } from '../../default/utils/notify.js';
import { mockEdgelessTheme } from '../mock-services.js';
import { generateRoomId } from '../sync/websocket/utils.js';
import type { DocsPanel } from './docs-panel.js';
import type { LeftSidePanel } from './left-side-panel.js';
const basePath =
'https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.11.2/dist/';
setBasePath(basePath);
@customElement('collab-debug-menu')
export class CollabDebugMenu extends SignalWatcher(ShadowlessElement) {
static override styles = css`
:root {
--sl-font-size-medium: var(--affine-font-xs);
--sl-input-font-size-small: var(--affine-font-xs);
}
.dg.ac {
z-index: 1001 !important;
}
.top-container {
display: flex;
align-items: center;
gap: 12px;
font-size: 16px;
}
`;
private _darkModeChange = (e: MediaQueryListEvent) => {
this._setThemeMode(!!e.matches);
};
private _handleDocsPanelClose = () => {
this.leftSidePanel.toggle(this.docsPanel);
};
private _keydown = (e: KeyboardEvent) => {
if (e.key === 'F1') {
this._switchEditorMode();
}
};
private _startCollaboration = async () => {
if (window.wsProvider) {
notify('There is already a websocket provider exists', 'neutral').catch(
console.error
);
return;
}
const params = new URLSearchParams(location.search);
const id = params.get('room') || (await generateRoomId());
params.set('room', id);
const url = new URL(location.href);
url.search = params.toString();
location.href = url.href;
};
get doc() {
return this.editor.doc;
}
get editorMode() {
return this.editor.mode;
}
set editorMode(value: DocMode) {
this.editor.mode = value;
}
get rootService() {
try {
return this.editor.std.getService('affine:page');
} catch {
return null;
}
}
private _addNote() {
const rootModel = this.doc.root;
if (!rootModel) return;
const rootId = rootModel.id;
this.doc.captureSync();
const count = rootModel.children.length;
const xywh: SerializedXYWH = `[0,${count * 60},800,95]`;
const noteId = this.doc.addBlock('affine:note', { xywh }, rootId);
this.doc.addBlock('affine:paragraph', {}, noteId);
}
private async _clearSiteData() {
await fetch('/Clear-Site-Data');
window.location.reload();
}
private _exportHtml() {
const htmlTransformer = this.rootService?.transformers.html;
htmlTransformer?.exportDoc(this.doc).catch(console.error);
}
private _exportMarkDown() {
const markdownTransformer = this.rootService?.transformers.markdown;
markdownTransformer?.exportDoc(this.doc).catch(console.error);
}
private _exportPdf() {
this.editor.std.get(ExportManager).exportPdf().catch(console.error);
}
private _exportPng() {
this.editor.std.get(ExportManager).exportPng().catch(console.error);
}
private async _exportSnapshot() {
if (!this.rootService) return;
const zipTransformer = this.rootService.transformers.zip;
await zipTransformer.exportDocs(
this.collection,
[...this.collection.docs.values()].map(collection => collection.getDoc())
);
}
private _importSnapshot() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', '.zip');
input.multiple = false;
input.onchange = async () => {
const file = input.files?.item(0);
if (!file) return;
if (!this.rootService) return;
try {
const zipTransformer = this.rootService.transformers.zip;
const docs = await zipTransformer.importDocs(this.collection, file);
for (const doc of docs) {
let noteBlockId;
const noteBlocks = window.doc.getBlocksByFlavour('affine:note');
if (noteBlocks.length) {
noteBlockId = noteBlocks[0].id;
} else {
noteBlockId = this.doc.addBlock(
'affine:note',
{
xywh: '[-200,-48,400,96]',
},
this.doc.root?.id
);
}
if (!doc) {
break;
}
window.doc.addBlock(
'affine:paragraph',
{
type: 'text',
text: new Text([
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: doc.id,
},
},
} as DeltaInsert<AffineTextAttributes>,
]),
},
noteBlockId
);
}
this.requestUpdate();
} catch (e) {
console.error('Invalid snapshot.');
console.error(e);
} finally {
input.remove();
}
};
input.click();
}
private _insertTransitionStyle(classKey: string, duration: number) {
const $html = document.documentElement;
const $style = document.createElement('style');
const slCSSKeys = ['sl-transition-x-fast'];
$style.innerHTML = `html.${classKey} * { transition: all ${duration}ms 0ms linear !important; } :root { ${slCSSKeys.map(
key => `--${key}: ${duration}ms`
)} }`;
$html.append($style);
$html.classList.add(classKey);
setTimeout(() => {
$style.remove();
$html.classList.remove(classKey);
}, duration);
}
private _print() {
printToPdf().catch(console.error);
}
private _setThemeMode(dark: boolean) {
const html = document.querySelector('html');
this._dark = dark;
localStorage.setItem('blocksuite:dark', dark ? 'true' : 'false');
if (!html) return;
html.dataset.theme = dark ? 'dark' : 'light';
this._insertTransitionStyle('color-transition', 0);
if (dark) {
html.classList.add('dark');
html.classList.add('sl-theme-dark');
} else {
html.classList.remove('dark');
html.classList.remove('sl-theme-dark');
}
const theme = dark ? ColorScheme.Dark : ColorScheme.Light;
mockEdgelessTheme.setTheme(theme);
}
private _switchEditorMode() {
if (!this.editor.host) return;
const newMode = this._docMode === 'page' ? 'edgeless' : 'page';
const docModeService = this.editor.host.std.get(DocModeProvider);
if (docModeService) {
docModeService.setPrimaryMode(newMode, this.editor.doc.id);
}
this._docMode = newMode;
this.editor.mode = newMode;
}
private _toggleDarkMode() {
this._setThemeMode(!this._dark);
}
private _toggleDocsPanel() {
this.docsPanel.onClose = this._handleDocsPanelClose;
this.leftSidePanel.toggle(this.docsPanel);
}
override connectedCallback() {
super.connectedCallback();
this._docMode = this.editor.mode;
this.editor.slots.docUpdated.on(({ newDocId }) => {
const newDocMode = this.editor.std
.get(DocModeProvider)
.getPrimaryMode(newDocId);
this._docMode = newDocMode;
});
document.body.addEventListener('keydown', this._keydown);
}
override createRenderRoot() {
const matchMedia = window.matchMedia('(prefers-color-scheme: dark)');
this._setThemeMode(this._dark && matchMedia.matches);
matchMedia.addEventListener('change', this._darkModeChange);
return this;
}
override disconnectedCallback() {
super.disconnectedCallback();
const matchMedia = window.matchMedia('(prefers-color-scheme: dark)');
matchMedia.removeEventListener('change', this._darkModeChange);
document.body.removeEventListener('keydown', this._keydown);
}
override firstUpdated() {
this.doc.slots.historyUpdated.on(() => {
this._canUndo = this.doc.canUndo;
this._canRedo = this.doc.canRedo;
});
}
override render() {
return html`
<style>
.collab-debug-menu {
display: flex;
flex-wrap: nowrap;
position: fixed;
top: 0;
left: 0;
width: 100%;
overflow: auto;
z-index: 1000; /* for debug visibility */
pointer-events: none;
}
@media print {
.collab-debug-menu {
display: none;
}
}
.default-toolbar {
display: flex;
gap: 5px;
padding: 8px 8px 8px 16px;
width: 100%;
min-width: 390px;
align-items: center;
justify-content: space-between;
}
.default-toolbar sl-button.dots-menu::part(base) {
color: var(--sl-color-neutral-700);
}
.default-toolbar sl-button.dots-menu::part(label) {
padding-left: 0;
}
.default-toolbar > * {
pointer-events: auto;
}
.edgeless-toolbar {
align-items: center;
margin-right: 17px;
pointer-events: auto;
}
.edgeless-toolbar sl-select,
.edgeless-toolbar sl-color-picker,
.edgeless-toolbar sl-button {
margin-right: 4px;
}
</style>
<div class="collab-debug-menu default">
<div class="default-toolbar">
<div class="top-container">
<sl-dropdown placement="bottom" hoist>
<sl-button
class="dots-menu"
variant="text"
size="small"
slot="trigger"
>
<sl-icon
style="font-size: 14px"
name="three-dots-vertical"
label="Menu"
></sl-icon>
</sl-button>
<sl-menu>
<sl-menu-item>
<sl-icon
slot="prefix"
name="terminal"
label="Test operations"
></sl-icon>
<span>Test operations</span>
<sl-menu slot="submenu">
<sl-menu-item @click="${this._print}"> Print </sl-menu-item>
<sl-menu-item @click=${this._addNote}>
Add Note</sl-menu-item
>
<sl-menu-item @click=${this._exportMarkDown}>
Export Markdown
</sl-menu-item>
<sl-menu-item @click=${this._exportHtml}>
Export HTML
</sl-menu-item>
<sl-menu-item @click=${this._exportPdf}>
Export PDF
</sl-menu-item>
<sl-menu-item @click=${this._exportPng}>
Export PNG
</sl-menu-item>
<sl-menu-item @click=${this._exportSnapshot}>
Export Snapshot
</sl-menu-item>
<sl-menu-item @click=${this._importSnapshot}>
Import Snapshot
</sl-menu-item>
</sl-menu>
</sl-menu-item>
<sl-menu-item @click=${this._clearSiteData}>
Clear Site Data
<sl-icon slot="prefix" name="trash"></sl-icon>
</sl-menu-item>
<sl-menu-item @click=${this._toggleDarkMode}>
Toggle ${this._dark ? 'Light' : 'Dark'} Mode
<sl-icon
slot="prefix"
name=${this._dark ? 'moon' : 'brightness-high'}
></sl-icon>
</sl-menu-item>
<sl-divider></sl-divider>
<a
target="_blank"
href="https://github.com/toeverything/blocksuite"
>
<sl-menu-item>
<sl-icon slot="prefix" name="github"></sl-icon>
GitHub
</sl-menu-item>
</a>
</sl-menu>
</sl-dropdown>
<!-- undo/redo group -->
<sl-button-group label="History">
<!-- undo -->
<sl-tooltip content="Undo" placement="bottom" hoist>
<sl-button
pill
size="small"
content="Undo"
.disabled=${!this._canUndo}
@click=${() => {
this.doc.undo();
}}
>
<sl-icon name="arrow-counterclockwise" label="Undo"></sl-icon>
</sl-button>
</sl-tooltip>
<!-- redo -->
<sl-tooltip content="Redo" placement="bottom" hoist>
<sl-button
pill
size="small"
content="Redo"
.disabled=${!this._canRedo}
@click=${() => {
this.doc.redo();
}}
>
<sl-icon name="arrow-clockwise" label="Redo"></sl-icon>
</sl-button>
</sl-tooltip>
</sl-button-group>
<sl-tooltip content="Start Collaboration" placement="bottom" hoist>
<sl-button @click=${this._startCollaboration} size="small" circle>
<sl-icon name="people" label="Collaboration"></sl-icon>
</sl-button>
</sl-tooltip>
<sl-tooltip content="Docs" placement="bottom" hoist>
<sl-button
@click=${this._toggleDocsPanel}
size="small"
circle
data-docs-panel-toggle
>
<sl-icon name="filetype-doc" label="Doc"></sl-icon>
</sl-button>
</sl-tooltip>
${new URLSearchParams(location.search).get('room')
? html`<sl-input
placeholder="Your name in room"
clearable
size="small"
@blur=${(e: Event) => {
if ((e.target as HTMLInputElement).value.length > 0) {
this.collection.awarenessStore.awareness.setLocalStateField(
'user',
{
name: (e.target as HTMLInputElement).value ?? '',
}
);
} else {
this.collection.awarenessStore.awareness.setLocalStateField(
'user',
{
name: 'Unknown',
}
);
}
}}
></sl-input
></sl-tooltip>`
: nothing}
</div>
<div style="display: flex; gap: 12px">
<!-- Edgeless Theme button -->
${this._docMode === 'edgeless'
? html`<sl-tooltip
content="Edgeless Theme"
placement="bottom"
hoist
>
<sl-button
size="small"
circle
@click=${() => mockEdgelessTheme.toggleTheme()}
>
<sl-icon
name="${mockEdgelessTheme.theme$.value === 'dark'
? 'moon'
: 'brightness-high'}"
label="Edgeless Theme"
></sl-icon>
</sl-button>
</sl-tooltip>`
: nothing}
<!-- Present button -->
${this._docMode === 'edgeless'
? html`<sl-tooltip content="Present" placement="bottom" hoist>
<sl-button
size="small"
circle
@click=${() => {
if (this.rootService instanceof EdgelessRootService) {
this.rootService.gfx.tool.setTool('frameNavigator', {
mode: 'fit',
});
}
}}
>
<sl-icon name="easel" label="Present"></sl-icon>
</sl-button>
</sl-tooltip>`
: nothing}
<sl-button-group label="Mode" style="margin-right: 12px">
<!-- switch to page -->
<sl-tooltip content="Page" placement="bottom" hoist>
<sl-button
pill
size="small"
content="Page"
.disabled=${this._docMode !== 'edgeless'}
@click=${this._switchEditorMode}
>
<sl-icon name="filetype-doc" label="Page"></sl-icon>
</sl-button>
</sl-tooltip>
<!-- switch to edgeless -->
<sl-tooltip content="Edgeless" placement="bottom" hoist>
<sl-button
pill
size="small"
content="Edgeless"
.disabled=${this._docMode !== 'page'}
@click=${this._switchEditorMode}
>
<sl-icon name="palette" label="Edgeless"></sl-icon>
</sl-button>
</sl-tooltip>
</sl-button-group>
</div>
</div>
</div>
`;
}
@state()
private accessor _canRedo = false;
@state()
private accessor _canUndo = false;
@state()
private accessor _dark = localStorage.getItem('blocksuite:dark') === 'true';
@state()
private accessor _docMode: DocMode = 'page';
@property({ attribute: false })
accessor collection!: DocCollection;
@property({ attribute: false })
accessor docsPanel!: DocsPanel;
@property({ attribute: false })
accessor editor!: AffineEditorContainer;
@property({ attribute: false })
accessor leftSidePanel!: LeftSidePanel;
@property({ attribute: false })
accessor readonly = false;
}
declare global {
interface HTMLElementTagNameMap {
'collab-debug-menu': CollabDebugMenu;
}
}
@@ -0,0 +1,69 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import { effect } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('custom-frame-panel')
export class CustomFramePanel extends WithDisposable(ShadowlessElement) {
static override styles = css`
.custom-frame-container {
position: absolute;
top: 0;
right: 0;
border: 1px solid var(--affine-border-color, #e3e2e4);
background-color: var(--affine-background-primary-color);
height: 100vh;
width: 320px;
box-sizing: border-box;
padding-top: 16px;
z-index: 1;
}
`;
private _renderPanel() {
return html`<affine-frame-panel
.host=${this.editor.std.host}
></affine-frame-panel>`;
}
override connectedCallback(): void {
super.connectedCallback();
this.disposables.add(
effect(() => {
const std = this.editor.std;
if (std) {
this.editor.updateComplete
.then(() => this.requestUpdate())
.catch(console.error);
}
})
);
}
override render() {
return html`
${this._show
? html`<div class="custom-frame-container">${this._renderPanel()}</div>`
: nothing}
`;
}
toggleDisplay() {
this._show = !this._show;
}
@state()
private accessor _show = false;
@property({ attribute: false })
accessor editor!: AffineEditorContainer;
}
declare global {
interface HTMLElementTagNameMap {
'custom-frame-panel': CustomFramePanel;
}
}
@@ -0,0 +1,54 @@
import { WithDisposable } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('custom-outline-panel')
export class CustomOutlinePanel extends WithDisposable(LitElement) {
static override styles = css`
.custom-outline-container {
position: absolute;
top: 0;
right: 16px;
border: 1px solid var(--affine-border-color, #e3e2e4);
background: var(--affine-background-overlay-panel-color);
height: 100vh;
width: 320px;
box-sizing: border-box;
z-index: 1;
}
`;
private _renderPanel() {
return html`<affine-outline-panel
.editor=${this.editor}
.fitPadding=${[50, 360, 50, 50]}
></affine-outline-panel>`;
}
override render() {
return html`
${this._show
? html`
<div class="custom-outline-container">${this._renderPanel()}</div>
`
: nothing}
`;
}
toggleDisplay() {
this._show = !this._show;
}
@state()
private accessor _show = false;
@property({ attribute: false })
accessor editor!: AffineEditorContainer;
}
declare global {
interface HTMLElementTagNameMap {
'custom-outline-panel': CustomOutlinePanel;
}
}
@@ -0,0 +1,51 @@
import { WithDisposable } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('custom-outline-viewer')
export class CustomOutlineViewer extends WithDisposable(LitElement) {
static override styles = css`
.outline-viewer-container {
position: fixed;
display: flex;
top: 256px;
right: 22px;
max-height: calc(100vh - 256px - 76px); // top(256px) and bottom(76px)
}
`;
private _renderViewer() {
return html`<affine-outline-viewer
.editor=${this.editor}
.toggleOutlinePanel=${this.toggleOutlinePanel}
></affine-outline-viewer>`;
}
override render() {
if (!this._show || this.editor.mode === 'edgeless') return nothing;
return html`<div class="outline-viewer-container">
${this._renderViewer()}
</div>`;
}
toggleDisplay() {
this._show = !this._show;
}
@state()
private accessor _show = false;
@property({ attribute: false })
accessor editor!: AffineEditorContainer;
@property({ attribute: false })
accessor toggleOutlinePanel: (() => void) | null = null;
}
declare global {
interface HTMLElementTagNameMap {
'custom-outline-viewer': CustomOutlineViewer;
}
}
@@ -0,0 +1,137 @@
export const demoScript = `import * as THREE from "three";
import {OrbitControls} from "three/addons/controls/OrbitControls.js";
let scene = new THREE.Scene();
let camera = new THREE.PerspectiveCamera(30, innerWidth / innerHeight, 1, 1000);
camera.position.set(0, 10, 10).setLength(17);
let renderer = new THREE.WebGLRenderer({antialias: true});
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener("resize", event => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
})
let controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
let gu = {
time: {value: 0}
}
let params = {
instanceCount: {value: 10},
instanceLength: {value: 1.75},
instanceGap: {value: 0.5},
profileFactor: {value: 1.5}
}
let ig = new THREE.InstancedBufferGeometry().copy(new THREE.BoxGeometry(1, 1, 1, 100, 1, 1).translate(0.5, 0, 0));
ig.instanceCount = params.instanceCount.value;
let m = new THREE.MeshBasicMaterial({
vertexColors: true,
onBeforeCompile: shader => {
shader.uniforms.time = gu.time;
shader.uniforms.instanceCount = params.instanceCount;
shader.uniforms.instanceLength = params.instanceLength;
shader.uniforms.instanceGap = params.instanceGap;
shader.uniforms.profileFactor = params.profileFactor;
shader.vertexShader = \`
uniform float time;
uniform float instanceCount;
uniform float instanceLength;
uniform float instanceGap;
uniform float profileFactor;
varying float noGrid;
mat2 rot(float a){return mat2(cos(a), sin(a), -sin(a), cos(a));}
\${shader.vertexShader}
\`.replace(
\`#include <begin_vertex>\`,
\`#include <begin_vertex>
float t = time * 0.1;
float iID = float(gl_InstanceID);
float instanceTotalLength = instanceLength + instanceGap;
float instanceFactor = instanceLength / instanceTotalLength;
float circleLength = instanceTotalLength * instanceCount;
float circleRadius = circleLength / PI2;
float partAngle = PI2 / instanceCount;
float boxAngle = partAngle * instanceFactor;
float partTurn = PI / instanceCount;
float boxTurn = partTurn * instanceFactor;
float startAngle = t + partAngle * iID;
float startTurn = t * 0.5 + partTurn * iID;
float angleFactor = position.x;
float angle = startAngle + boxAngle * angleFactor;
float turn = startTurn + boxTurn * angleFactor;
vec3 pos = vec3(0, position.y, position.z);
pos.yz *= rot(turn);
pos.yz *= profileFactor;
pos.z += circleRadius;
pos.xz *= rot(angle);
transformed = pos;
float nZ = floor(abs(normal.z) + 0.1);
float nX = floor(abs(normal.x) + 0.1);
noGrid = 1. - nX;
vColor = vec3(nZ == 1. ? 0.1 : nX == 1. ? 0. : 0.01);
\`
);
//console.log(shader.vertexShader);
shader.fragmentShader = \`
varying float noGrid;
float lines(vec2 coord, float thickness){
vec2 grid = abs(fract(coord - 0.5) - 0.5) / fwidth(coord) / thickness;
float line = min(grid.x, grid.y);
return 1.0 - min(line, 1.0);
}
\${shader.fragmentShader}
\`.replace(
\`#include <color_fragment>\`,
\`#include <color_fragment>
float multiply = vColor.r > 0.05 ? 3. : 2.;
float edges = lines(vUv, 3.);
float grid = min(noGrid, lines(vUv * multiply, 1.));
diffuseColor.rgb = mix(diffuseColor.rgb, vec3(1), max(edges, grid));
\`
)
//console.log(shader.fragmentShader)
}
});
m.defines = {"USE_UV": ""};
let o = new THREE.Mesh(ig, m);
scene.add(o)
o.rotation.z = -Math.PI * 0.25;
let clock = new THREE.Clock();
let t = 0;
renderer.setAnimationLoop(()=>{
let dt = clock.getDelta();
t += dt;
gu.time.value = t;
controls.update();
renderer.render(scene, camera);
})
`;
@@ -0,0 +1,181 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import {
CloseIcon,
createDefaultDoc,
GenerateDocUrlProvider,
} from '@blocksuite/blocks';
import { WithDisposable } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import type { BlockCollection, DocCollection } from '@blocksuite/store';
import { css, html, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { removeModeFromStorage } from '../mock-services.js';
@customElement('docs-panel')
export class DocsPanel extends WithDisposable(ShadowlessElement) {
static override styles = css`
docs-panel {
display: flex;
flex-direction: column;
width: 100%;
background-color: var(--affine-background-secondary-color);
font-family: var(--affine-font-family);
height: 100%;
padding: 12px;
gap: 4px;
}
.doc-item:hover .delete-doc-icon {
display: flex;
}
.doc-item {
color: var(--affine-text-primary-color);
}
.delete-doc-icon {
display: none;
padding: 2px;
border-radius: 4px;
}
.delete-doc-icon:hover {
background-color: var(--affine-hover-color);
}
.delete-doc-icon svg {
width: 14px;
height: 14px;
color: var(--affine-secondary-color);
fill: var(--affine-secondary-color);
}
.new-doc-button {
margin-bottom: 16px;
border: 1px solid var(--affine-border-color);
border-radius: 4px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--affine-text-primary-color);
}
.new-doc-button:hover {
background-color: var(--affine-hover-color);
}
`;
createDoc = () => {
createDocBlock(this.editor.doc.collection);
};
gotoDoc = (doc: BlockCollection) => {
const url = this.editor.std
.getOptional(GenerateDocUrlProvider)
?.generateDocUrl(doc.id);
if (url) history.pushState({}, '', url);
this.editor.doc = doc.getDoc();
this.editor.doc.load();
this.editor.doc.resetHistory();
this.requestUpdate();
};
private get collection() {
return this.editor.doc.collection;
}
private get docs() {
return [...this.collection.docs.values()];
}
override connectedCallback() {
super.connectedCallback();
requestAnimationFrame(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!(event.target instanceof Node)) return;
const toggleButton = document.querySelector(
'sl-button[data-docs-panel-toggle]'
);
if (toggleButton?.contains(event.target as Node)) return;
if (!this.contains(event.target)) {
this.onClose?.();
}
};
document.addEventListener('click', handleClickOutside);
this.disposables.add(() => {
document.removeEventListener('click', handleClickOutside);
});
});
this.disposables.add(
this.editor.doc.collection.slots.docUpdated.on(() => {
this.requestUpdate();
})
);
}
protected override render(): unknown {
const { docs, collection } = this;
return html`
<div @click="${this.createDoc}" class="new-doc-button">New Doc</div>
${repeat(
docs,
v => v.id,
doc => {
const style = styleMap({
backgroundColor:
this.editor.doc.id === doc.id
? 'var(--affine-hover-color)'
: undefined,
padding: '4px 4px 4px 8px',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
});
const click = () => {
this.gotoDoc(doc);
};
const deleteDoc = (e: MouseEvent) => {
e.stopPropagation();
const isDeleteCurrent = doc.id === this.editor.doc.id;
collection.removeDoc(doc.id);
removeModeFromStorage(doc.id);
// When delete the current doc, we need to set the editor doc to the first remaining doc
if (isDeleteCurrent) {
this.editor.doc = this.docs[0].getDoc();
}
};
return html`<div class="doc-item" @click="${click}" style="${style}">
${doc.meta?.title || 'Untitled'}
${docs.length > 1
? html`<div @click="${deleteDoc}" class="delete-doc-icon">
${CloseIcon}
</div>`
: nothing}
</div>`;
}
)}
`;
}
@property({ attribute: false })
accessor editor!: AffineEditorContainer;
@property({ attribute: false })
accessor onClose!: () => void;
}
function createDocBlock(collection: DocCollection) {
const id = collection.idGenerator();
createDefaultDoc(collection, { id });
}
declare global {
interface HTMLElementTagNameMap {
'docs-panel': DocsPanel;
}
}
@@ -0,0 +1,55 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { css, html } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('left-side-panel')
export class LeftSidePanel extends ShadowlessElement {
static override styles = css`
left-side-panel {
padding-top: 50px;
width: 300px;
position: absolute;
top: 0;
left: 0;
height: 100%;
display: none;
}
`;
currentContent: HTMLElement | null = null;
hideContent() {
if (this.currentContent) {
this.style.display = 'none';
this.currentContent.remove();
this.currentContent = null;
}
}
protected override render(): unknown {
return html``;
}
showContent(ele: HTMLElement) {
if (this.currentContent) {
this.currentContent.remove();
}
this.style.display = 'block';
this.currentContent = ele;
this.append(ele);
}
toggle(ele: HTMLElement) {
if (this.currentContent !== ele) {
this.showContent(ele);
} else {
this.hideContent();
}
}
}
declare global {
interface HTMLElementTagNameMap {
'left-side-panel': LeftSidePanel;
}
}
@@ -0,0 +1,64 @@
export enum State {
IDLE = 0,
Connecting,
Connected,
Opening,
Opened,
Failed,
}
export type DocInfo = {
total: number;
width: number;
height: number;
};
export type ViewportInfo = {
dpi: number;
width: number;
height: number;
};
export enum MessageState {
Poll,
Ready,
}
export enum MessageOp {
Init,
Inited,
Open,
Opened,
Render,
Rendered,
}
export enum RenderKind {
Page,
Thumbnail,
}
export interface MessageDataMap {
[MessageOp.Init]: undefined;
[MessageOp.Inited]: undefined;
[MessageOp.Open]: ArrayBuffer;
[MessageOp.Opened]: DocInfo;
[MessageOp.Render]: {
index: number;
kind: RenderKind;
scale: number;
};
[MessageOp.Rendered]: {
index: number;
kind: RenderKind;
imageData: ImageData;
};
}
export type MessageDataType<T = MessageDataMap> = {
[P in keyof T]: T[P];
};
export type MessageData<T = MessageOp, P = MessageDataType> = {
type: T;
} & P;
@@ -0,0 +1,124 @@
import type { Document } from '@toeverything/pdf-viewer';
import {
createPDFium,
PageRenderingflags,
Runtime,
Viewer,
} from '@toeverything/pdf-viewer';
import wasmUrl from '@toeverything/pdfium/wasm?url';
import { type MessageData, type MessageDataType, MessageOp } from './types';
let inited = false;
let viewer: Viewer | null = null;
let doc: Document | undefined = undefined;
const docInfo = { total: 0, width: 1, height: 1 };
const flags = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT;
function post<T extends MessageOp>(type: T, data?: MessageDataType[T]) {
const message = { type, [type]: data };
self.postMessage(message);
}
function renderToImageData(index: number, scale: number) {
if (!viewer || !doc) return;
const page = doc.page(index);
if (!page) return;
const width = Math.ceil(docInfo.width * scale);
const height = Math.ceil(docInfo.height * scale);
const bitmap = viewer.createBitmap(width, height, 0);
bitmap.fill(0, 0, width, height);
page.render(bitmap, 0, 0, width, height, 0, flags);
// @ts-expect-error FIXME: ts error
const data = new Uint8ClampedArray(bitmap.toUint8Array());
bitmap.close();
page.close();
return new ImageData(data, width, height);
}
async function start() {
inited = true;
console.debug('pdf worker pending');
self.postMessage({ type: MessageOp.Init });
const pdfium = await createPDFium({
// @ts-expect-error allow
locateFile: () => wasmUrl,
});
viewer = new Viewer(new Runtime(pdfium));
self.postMessage({ type: MessageOp.Inited });
console.debug('pdf worker ready');
}
async function process({ data }: MessageEvent<MessageData>) {
if (!inited) {
await start();
}
if (!viewer) return;
const { type } = data;
switch (type) {
case MessageOp.Open: {
const buffer = data[type];
if (!buffer) return;
doc = viewer.open(new Uint8Array(buffer));
if (!doc) return;
const page = doc.page(0);
if (!page) return;
Object.assign(docInfo, {
total: doc.pageCount(),
height: Math.ceil(page.height()),
width: Math.ceil(page.width()),
});
page.close();
post(MessageOp.Opened, docInfo);
break;
}
case MessageOp.Render: {
if (!doc) return;
const { index, kind, scale } = data[type];
const { total } = docInfo;
if (index < 0 || index >= total) return;
queueMicrotask(() => {
const imageData = renderToImageData(index, scale);
if (!imageData) return;
post(MessageOp.Rendered, { index, kind, imageData });
});
break;
}
}
}
self.addEventListener('message', (event: MessageEvent<MessageData>) => {
process(event).catch(console.error);
});
start().catch(error => {
inited = false;
console.log(error);
});
@@ -0,0 +1,49 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { css, html } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('side-panel')
export class SidePanel extends ShadowlessElement {
static override styles = css`
side-panel {
width: 395px;
background-color: var(--affine-background-secondary-color);
position: absolute;
top: 0;
right: 0;
height: 100%;
display: none;
}
`;
currentContent: HTMLElement | null = null;
hideContent() {
if (this.currentContent) {
this.style.display = 'none';
this.currentContent.remove();
this.currentContent = null;
}
}
protected override render(): unknown {
return html``;
}
showContent(ele: HTMLElement) {
if (this.currentContent) {
this.currentContent.remove();
}
this.style.display = 'block';
this.currentContent = ele;
this.append(ele);
}
toggle(ele: HTMLElement) {
if (this.currentContent !== ele) {
this.showContent(ele);
} else {
this.hideContent();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
import type { DocModeProvider } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import type { BlockCollection, Doc, DocCollection } from '@blocksuite/store';
import type { LitElement } from 'lit';
export function getDocFromUrlParams(collection: DocCollection, url: URL) {
let doc: Doc | null = null;
const docId = decodeURIComponent(url.hash.slice(1));
if (docId) {
doc = collection.getDoc(docId);
}
if (!doc) {
const blockCollection = collection.docs.values().next()
.value as BlockCollection;
assertExists(blockCollection, 'Need to create a doc first');
doc = blockCollection.getDoc();
}
doc.load();
doc.resetHistory();
assertExists(doc.ready, 'Doc is not ready');
assertExists(doc.root, 'Doc root is not ready');
return doc;
}
export function setDocModeFromUrlParams(
service: DocModeProvider,
search: URLSearchParams,
docId: string
) {
const paramMode = search.get('mode');
if (paramMode) {
const docMode = paramMode === 'page' ? 'page' : 'edgeless';
service.setPrimaryMode(docMode, docId);
service.setEditorMode(docMode);
}
}
export function listenHashChange(
collection: DocCollection,
editor: AffineEditorContainer,
panel?: LitElement
) {
window.addEventListener('hashchange', () => {
const url = new URL(location.toString());
const doc = getDocFromUrlParams(collection, url);
if (!doc) return;
if (panel?.checkVisibility()) {
panel.requestUpdate();
}
editor.doc = doc;
editor.doc.load();
editor.doc.resetHistory();
});
}
@@ -0,0 +1,206 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type {
PeekOptions,
PeekViewService,
} from '@blocksuite/affine-components/peek';
import { PeekViewExtension } from '@blocksuite/affine-components/peek';
import { BlockComponent } from '@blocksuite/block-std';
import {
ColorScheme,
type DocMode,
type DocModeProvider,
type GenerateDocUrlService,
matchFlavours,
type NotificationService,
type ParseDocUrlService,
type ReferenceParams,
type ThemeExtension,
toast,
} from '@blocksuite/blocks';
import type { AffineEditorContainer } from '@blocksuite/presets';
import { type DocCollection, Slot } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import type { TemplateResult } from 'lit';
import type { AttachmentViewerPanel } from './components/attachment-viewer-panel.js';
function getModeFromStorage() {
const mapJson = localStorage.getItem('playground:docMode');
const mapArray = mapJson ? JSON.parse(mapJson) : [];
return new Map<string, DocMode>(mapArray);
}
function saveModeToStorage(map: Map<string, DocMode>) {
const mapArray = Array.from(map);
const mapJson = JSON.stringify(mapArray);
localStorage.setItem('playground:docMode', mapJson);
}
export function removeModeFromStorage(docId: string) {
const modeMap = getModeFromStorage();
modeMap.delete(docId);
saveModeToStorage(modeMap);
}
const DEFAULT_MODE: DocMode = 'page';
const slotMap = new Map<string, Slot<DocMode>>();
export function mockDocModeService(
getEditorModeCallback: () => DocMode,
setEditorModeCallback: (mode: DocMode) => void
) {
const docModeService: DocModeProvider = {
getPrimaryMode: (docId: string) => {
try {
const modeMap = getModeFromStorage();
return modeMap.get(docId) ?? DEFAULT_MODE;
} catch {
return DEFAULT_MODE;
}
},
onPrimaryModeChange: (handler: (mode: DocMode) => void, docId: string) => {
if (!slotMap.get(docId)) {
slotMap.set(docId, new Slot());
}
return slotMap.get(docId)!.on(handler);
},
getEditorMode: () => {
return getEditorModeCallback();
},
setEditorMode: (mode: DocMode) => {
setEditorModeCallback(mode);
},
setPrimaryMode: (mode: DocMode, docId: string) => {
const modeMap = getModeFromStorage();
modeMap.set(docId, mode);
saveModeToStorage(modeMap);
slotMap.get(docId)?.emit(mode);
},
togglePrimaryMode: (docId: string) => {
const mode =
docModeService.getPrimaryMode(docId) === 'page' ? 'edgeless' : 'page';
docModeService.setPrimaryMode(mode, docId);
return mode;
},
};
return docModeService;
}
export function mockNotificationService(editor: AffineEditorContainer) {
const notificationService: NotificationService = {
toast: (message, options) => {
toast(editor.host!, message, options?.duration);
},
confirm: notification => {
return Promise.resolve(confirm(notification.title.toString()));
},
prompt: notification => {
return Promise.resolve(
prompt(notification.title.toString(), notification.autofill?.toString())
);
},
notify: notification => {
// todo: implement in playground
console.log(notification);
},
};
return notificationService;
}
export function mockParseDocUrlService(collection: DocCollection) {
const parseDocUrlService: ParseDocUrlService = {
parseDocUrl: (url: string) => {
if (url && URL.canParse(url)) {
const path = decodeURIComponent(new URL(url).hash.slice(1));
const item =
path.length > 0
? [...collection.docs.values()].find(doc => doc.id === path)
: null;
if (item) {
return {
docId: item.id,
};
}
}
return;
},
};
return parseDocUrlService;
}
export class MockEdgelessTheme {
theme$ = signal(ColorScheme.Light);
setTheme(theme: ColorScheme) {
this.theme$.value = theme;
}
toggleTheme() {
const theme =
this.theme$.value === ColorScheme.Dark
? ColorScheme.Light
: ColorScheme.Dark;
this.theme$.value = theme;
}
}
export const mockEdgelessTheme = new MockEdgelessTheme();
export const themeExtension: ThemeExtension = {
getEdgelessTheme() {
return mockEdgelessTheme.theme$;
},
};
export function mockPeekViewExtension(
attachmentViewerPanel: AttachmentViewerPanel
) {
return PeekViewExtension({
peek(
element: {
target: HTMLElement;
docId: string;
blockIds?: string[];
template?: TemplateResult;
},
options?: PeekOptions
) {
const { target } = element;
if (
target instanceof BlockComponent &&
matchFlavours(target.model, ['affine:attachment'])
) {
attachmentViewerPanel.open(target.model);
return Promise.resolve();
}
alert('Peek view not implemented in playground');
console.log('peek', element, options);
return Promise.resolve();
},
} satisfies PeekViewService);
}
export function mockGenerateDocUrlService(collection: DocCollection) {
const generateDocUrlService: GenerateDocUrlService = {
generateDocUrl: (docId: string, params?: ReferenceParams) => {
const doc = collection.getDoc(docId);
if (!doc) return;
const url = new URL(location.pathname, location.origin);
url.search = location.search;
if (params) {
const search = url.searchParams;
for (const [key, value] of Object.entries(params)) {
search.set(key, Array.isArray(value) ? value.join(',') : value);
}
}
url.hash = encodeURIComponent(docId);
return url.toString();
},
};
return generateDocUrlService;
}
@@ -0,0 +1,74 @@
import type {
Template,
TemplateCategory,
TemplateManager,
} from '@blocksuite/blocks';
import { EdgelessTemplatePanel } from '@blocksuite/blocks';
export function setupEdgelessTemplate() {
const playgroundTemplates = [
{
name: 'Paws and pals',
templates: () =>
import('./templates/stickers.js').then(module => module.default),
},
] as TemplateCategory[];
function lcs(text1: string, text2: string): number {
const dp: number[][] = Array.from(
{
length: text1.length + 1,
},
() => Array.from({ length: text2.length + 1 }, () => 0)
);
for (let i = 1; i <= text1.length; i++) {
for (let j = 1; j <= text2.length; j++) {
if (text1[i - 1] === text2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[text1.length][text2.length];
}
EdgelessTemplatePanel.templates.extend({
search: async (keyword: string) => {
const candidates: Template[] = [];
await Promise.all(
playgroundTemplates.map(async cate => {
const templates =
cate.templates instanceof Function
? await cate.templates()
: cate.templates;
templates.forEach(template => {
if (
template.name &&
lcs(template.name, keyword) === keyword.length
) {
candidates.push(template);
}
});
})
);
return candidates;
},
list: async (cate: string) => {
const category = playgroundTemplates.find(c => c.name === cate);
if (category?.templates instanceof Function) {
return category.templates();
}
return category?.templates ?? [];
},
categories: () => playgroundTemplates.map(cate => cate.name),
} satisfies TemplateManager);
}
@@ -0,0 +1,54 @@
import type { BlobSource } from '@blocksuite/sync';
/**
* @internal just for test
*
* API: /api/collection/:id/blob/:key
* GET: get blob
* PUT: set blob
* DELETE: delete blob
*/
export class MockServerBlobSource implements BlobSource {
private readonly _cache = new Map<string, Blob>();
readonly = false;
constructor(readonly name: string) {}
async delete(key: string) {
this._cache.delete(key);
await fetch(`/api/collection/${this.name}/blob/${key}`, {
method: 'DELETE',
});
}
async get(key: string) {
if (this._cache.has(key)) {
return this._cache.get(key) as Blob;
} else {
const blob = await fetch(`/api/collection/${this.name}/blob/${key}`, {
method: 'GET',
}).then(response => {
if (!response.ok) {
throw new Error(`Failed to fetch blob ${key}`);
}
return response.blob();
});
this._cache.set(key, blob);
return blob;
}
}
async list() {
return Array.from(this._cache.keys());
}
async set(key: string, value: Blob) {
this._cache.set(key, value);
await fetch(`/api/collection/${this.name}/blob/${key}`, {
method: 'PUT',
body: await value.arrayBuffer(),
});
return key;
}
}
@@ -0,0 +1,85 @@
import { assertExists } from '@blocksuite/global/utils';
import type { AwarenessSource } from '@blocksuite/sync';
import type { Awareness } from 'y-protocols/awareness';
import {
applyAwarenessUpdate,
encodeAwarenessUpdate,
} from 'y-protocols/awareness';
import type { WebSocketMessage } from './types';
type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>;
export class WebSocketAwarenessSource implements AwarenessSource {
private _onAwareness = (changes: AwarenessChanges, origin: unknown) => {
if (origin === 'remote') return;
const changedClients = Object.values(changes).reduce((res, cur) =>
res.concat(cur)
);
assertExists(this.awareness);
const update = encodeAwarenessUpdate(this.awareness, changedClients);
this.ws.send(
JSON.stringify({
channel: 'awareness',
payload: {
type: 'update',
update: Array.from(update),
},
} satisfies WebSocketMessage)
);
};
private _onWebSocket = (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as WebSocketMessage;
if (data.channel !== 'awareness') return;
const { type } = data.payload;
if (type === 'update') {
const update = data.payload.update;
assertExists(this.awareness);
applyAwarenessUpdate(this.awareness, new Uint8Array(update), 'remote');
}
if (type === 'connect') {
assertExists(this.awareness);
this.ws.send(
JSON.stringify({
channel: 'awareness',
payload: {
type: 'update',
update: Array.from(
encodeAwarenessUpdate(this.awareness, [this.awareness.clientID])
),
},
} satisfies WebSocketMessage)
);
}
};
awareness: Awareness | null = null;
constructor(readonly ws: WebSocket) {}
connect(awareness: Awareness): void {
this.awareness = awareness;
awareness.on('update', this._onAwareness);
this.ws.addEventListener('message', this._onWebSocket);
this.ws.send(
JSON.stringify({
channel: 'awareness',
payload: {
type: 'connect',
},
} satisfies WebSocketMessage)
);
}
disconnect(): void {
this.awareness?.off('update', this._onAwareness);
this.ws.close();
}
}
@@ -0,0 +1,103 @@
import { assertExists } from '@blocksuite/global/utils';
import type { DocSource } from '@blocksuite/sync';
import { diffUpdate, encodeStateVectorFromUpdate, mergeUpdates } from 'yjs';
import type { WebSocketMessage } from './types';
export class WebSocketDocSource implements DocSource {
private _onMessage = (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as WebSocketMessage;
if (data.channel !== 'doc') return;
if (data.payload.type === 'init') {
for (const [docId, data] of this.docMap) {
this.ws.send(
JSON.stringify({
channel: 'doc',
payload: {
type: 'update',
docId,
updates: Array.from(data),
},
} satisfies WebSocketMessage)
);
}
return;
}
const { docId, updates } = data.payload;
const update = this.docMap.get(docId);
if (update) {
this.docMap.set(docId, mergeUpdates([update, new Uint8Array(updates)]));
} else {
this.docMap.set(docId, new Uint8Array(updates));
}
};
docMap = new Map<string, Uint8Array>();
name = 'websocket';
constructor(readonly ws: WebSocket) {
this.ws.addEventListener('message', this._onMessage);
this.ws.send(
JSON.stringify({
channel: 'doc',
payload: {
type: 'init',
},
} satisfies WebSocketMessage)
);
}
pull(docId: string, state: Uint8Array) {
const update = this.docMap.get(docId);
if (!update) return null;
const diff = state.length ? diffUpdate(update, state) : update;
return { data: diff, state: encodeStateVectorFromUpdate(update) };
}
push(docId: string, data: Uint8Array) {
const update = this.docMap.get(docId);
if (update) {
this.docMap.set(docId, mergeUpdates([update, data]));
} else {
this.docMap.set(docId, data);
}
const latest = this.docMap.get(docId);
assertExists(latest);
this.ws.send(
JSON.stringify({
channel: 'doc',
payload: {
type: 'update',
docId,
updates: Array.from(latest),
},
} satisfies WebSocketMessage)
);
}
subscribe(cb: (docId: string, data: Uint8Array) => void) {
const abortController = new AbortController();
this.ws.addEventListener(
'message',
(event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as WebSocketMessage;
if (data.channel !== 'doc' || data.payload.type !== 'update') return;
const { docId, updates } = data.payload;
cb(docId, new Uint8Array(updates));
},
{ signal: abortController.signal }
);
return () => {
abortController.abort();
};
}
}
@@ -0,0 +1,19 @@
export type AwarenessMessage = {
channel: 'awareness';
payload: { type: 'connect' } | { type: 'update'; update: number[] };
};
export type DocMessage = {
channel: 'doc';
payload:
| {
type: 'init';
}
| {
type: 'update';
docId: string;
updates: number[];
};
};
export type WebSocketMessage = AwarenessMessage | DocMessage;
@@ -0,0 +1,8 @@
const BASE_URL = new URL(import.meta.env.PLAYGROUND_SERVER);
export async function generateRoomId(): Promise<string> {
return fetch(new URL('/room/', BASE_URL), {
method: 'post',
})
.then(res => res.json())
.then(({ id }) => id);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
import '../../style.css';
import '../dev-format.js';
import { effects as blocksEffects } from '@blocksuite/blocks/effects';
import { effects as presetsEffects } from '@blocksuite/presets/effects';
import { setupEdgelessTemplate } from '../_common/setup.js';
import {
createDefaultDocCollection,
initDefaultDocCollection,
} from './utils/collection.js';
import { mountDefaultDocEditor } from './utils/editor.js';
blocksEffects();
presetsEffects();
async function main() {
if (window.collection) return;
setupEdgelessTemplate();
const collection = await createDefaultDocCollection();
await initDefaultDocCollection(collection);
await mountDefaultDocEditor(collection);
}
main().catch(console.error);
@@ -0,0 +1,50 @@
import {
BlockFlavourIdentifier,
BlockServiceIdentifier,
type ExtensionType,
StdIdentifier,
} from '@blocksuite/block-std';
import {
AttachmentBlockService,
EdgelessEditorBlockSpecs,
PageEditorBlockSpecs,
} from '@blocksuite/blocks';
class CustomAttachmentBlockService extends AttachmentBlockService {
override mounted(): void {
super.mounted();
this.maxFileSize = 100 * 1000 * 1000; // 100MB
}
}
export function getCustomAttachmentSpecs() {
const pageModeSpecs: ExtensionType[] = [
...PageEditorBlockSpecs,
{
setup: di => {
di.override(
BlockServiceIdentifier('affine:attachment'),
CustomAttachmentBlockService,
[StdIdentifier, BlockFlavourIdentifier('affine:attachment')]
);
},
},
];
const edgelessModeSpecs: ExtensionType[] = [
...EdgelessEditorBlockSpecs,
{
setup: di => {
di.override(
BlockServiceIdentifier('affine:attachment'),
CustomAttachmentBlockService,
[StdIdentifier, BlockFlavourIdentifier('affine:attachment')]
);
},
},
];
return {
pageModeSpecs,
edgelessModeSpecs,
};
}
@@ -0,0 +1,26 @@
import {
EdgelessEditorBlockSpecs,
PageEditorBlockSpecs,
} from '@blocksuite/blocks';
import { getCustomAttachmentSpecs } from './custom-attachment/custom-attachment.js';
const params = new URLSearchParams(location.search);
export function getExampleSpecs() {
const type = params.get('exampleSpec');
let pageModeSpecs = PageEditorBlockSpecs;
let edgelessModeSpecs = EdgelessEditorBlockSpecs;
if (type === 'attachment') {
const specs = getCustomAttachmentSpecs();
pageModeSpecs = specs.pageModeSpecs;
edgelessModeSpecs = specs.edgelessModeSpecs;
}
return {
pageModeSpecs,
edgelessModeSpecs,
};
}
@@ -0,0 +1,109 @@
import { AffineSchemas } from '@blocksuite/blocks';
import type { BlockSuiteFlags } from '@blocksuite/global/types';
import {
DocCollection,
type DocCollectionOptions,
IdGeneratorType,
Job,
Schema,
Text,
} from '@blocksuite/store';
import {
BroadcastChannelAwarenessSource,
BroadcastChannelDocSource,
IndexedDBBlobSource,
IndexedDBDocSource,
} from '@blocksuite/sync';
import { WebSocketAwarenessSource } from '../../_common/sync/websocket/awareness';
import { WebSocketDocSource } from '../../_common/sync/websocket/doc';
const BASE_WEBSOCKET_URL = new URL(import.meta.env.PLAYGROUND_WS);
export async function createDefaultDocCollection() {
const idGenerator: IdGeneratorType = IdGeneratorType.NanoID;
const schema = new Schema();
schema.register(AffineSchemas);
const params = new URLSearchParams(location.search);
let docSources: DocCollectionOptions['docSources'] = {
main: new IndexedDBDocSource(),
};
let awarenessSources: DocCollectionOptions['awarenessSources'];
const room = params.get('room');
if (room) {
const ws = new WebSocket(new URL(`/room/${room}`, BASE_WEBSOCKET_URL));
await new Promise((resolve, reject) => {
ws.addEventListener('open', resolve);
ws.addEventListener('error', reject);
})
.then(() => {
docSources = {
main: new IndexedDBDocSource(),
shadows: [new WebSocketDocSource(ws)],
};
awarenessSources = [new WebSocketAwarenessSource(ws)];
})
.catch(() => {
docSources = {
main: new IndexedDBDocSource(),
shadows: [new BroadcastChannelDocSource()],
};
awarenessSources = [
new BroadcastChannelAwarenessSource('collabPlayground'),
];
});
}
const flags: Partial<BlockSuiteFlags> = Object.fromEntries(
[...params.entries()]
.filter(([key]) => key.startsWith('enable_'))
.map(([k, v]) => [k, v === 'true'])
);
const options: DocCollectionOptions = {
id: 'collabPlayground',
schema,
idGenerator,
blobSources: {
main: new IndexedDBBlobSource('collabPlayground'),
},
docSources,
awarenessSources,
defaultFlags: {
enable_synced_doc_block: true,
enable_pie_menu: true,
enable_lasso_tool: true,
enable_color_picker: true,
...flags,
},
};
const collection = new DocCollection(options);
collection.start();
// debug info
window.collection = collection;
window.blockSchemas = AffineSchemas;
window.job = new Job({ collection });
window.Y = DocCollection.Y;
return collection;
}
export async function initDefaultDocCollection(collection: DocCollection) {
const params = new URLSearchParams(location.search);
await collection.waitForSynced();
const shouldInit = collection.docs.size === 0 && !params.get('room');
if (shouldInit) {
collection.meta.initialize();
const doc = collection.createDoc({ id: 'doc:home' });
doc.load();
const rootId = doc.addBlock('affine:page', {
title: new Text(),
});
doc.addBlock('affine:surface', {}, rootId);
doc.resetHistory();
}
}
@@ -0,0 +1,141 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { EditorHost, ExtensionType } from '@blocksuite/block-std';
import {
CommunityCanvasTextFonts,
DocModeExtension,
DocModeProvider,
FontConfigExtension,
GenerateDocUrlExtension,
GenerateDocUrlProvider,
NotificationExtension,
OverrideThemeExtension,
ParseDocUrlExtension,
RefNodeSlotsExtension,
RefNodeSlotsProvider,
SpecProvider,
} from '@blocksuite/blocks';
import { AffineEditorContainer } from '@blocksuite/presets';
import type { DocCollection } from '@blocksuite/store';
import { AttachmentViewerPanel } from '../../_common/components/attachment-viewer-panel.js';
import { CollabDebugMenu } from '../../_common/components/collab-debug-menu.js';
import { DocsPanel } from '../../_common/components/docs-panel.js';
import { LeftSidePanel } from '../../_common/components/left-side-panel.js';
import {
getDocFromUrlParams,
listenHashChange,
setDocModeFromUrlParams,
} from '../../_common/history.js';
import {
mockDocModeService,
mockGenerateDocUrlService,
mockNotificationService,
mockParseDocUrlService,
mockPeekViewExtension,
themeExtension,
} from '../../_common/mock-services.js';
import { getExampleSpecs } from '../specs-examples/index.js';
export async function mountDefaultDocEditor(collection: DocCollection) {
const app = document.getElementById('app');
if (!app) return;
const url = new URL(location.toString());
const doc = getDocFromUrlParams(collection, url);
const attachmentViewerPanel = new AttachmentViewerPanel();
const editor = new AffineEditorContainer();
const specs = getExampleSpecs();
const refNodeSlotsExtension = RefNodeSlotsExtension();
editor.pageSpecs = patchPageRootSpec([
refNodeSlotsExtension,
...specs.pageModeSpecs,
]);
editor.edgelessSpecs = patchPageRootSpec([
refNodeSlotsExtension,
...specs.edgelessModeSpecs,
]);
SpecProvider.getInstance().extendSpec('edgeless:preview', [
OverrideThemeExtension(themeExtension),
]);
editor.doc = doc;
editor.mode = 'page';
editor.std
.get(RefNodeSlotsProvider)
.docLinkClicked.on(({ pageId: docId }) => {
const target = collection.getDoc(docId);
if (!target) {
throw new Error(`Failed to jump to doc ${docId}`);
}
const url = editor.std
.get(GenerateDocUrlProvider)
.generateDocUrl(target.id);
if (url) history.pushState({}, '', url);
target.load();
editor.doc = target;
});
app.append(editor);
await editor.updateComplete;
const modeService = editor.host!.std.get(DocModeProvider);
editor.mode = modeService.getPrimaryMode(doc.id);
setDocModeFromUrlParams(modeService, url.searchParams, doc.id);
editor.slots.docUpdated.on(({ newDocId }) => {
editor.mode = modeService.getPrimaryMode(newDocId);
});
const leftSidePanel = new LeftSidePanel();
const docsPanel = new DocsPanel();
docsPanel.editor = editor;
const collabDebugMenu = new CollabDebugMenu();
collabDebugMenu.collection = collection;
collabDebugMenu.editor = editor;
collabDebugMenu.leftSidePanel = leftSidePanel;
collabDebugMenu.docsPanel = docsPanel;
document.body.append(attachmentViewerPanel);
document.body.append(leftSidePanel);
document.body.append(collabDebugMenu);
// debug info
window.editor = editor;
window.doc = doc;
Object.defineProperty(globalThis, 'host', {
get() {
return document.querySelector<EditorHost>('editor-host');
},
});
Object.defineProperty(globalThis, 'std', {
get() {
return document.querySelector<EditorHost>('editor-host')?.std;
},
});
listenHashChange(collection, editor, docsPanel);
return editor;
function patchPageRootSpec(spec: ExtensionType[]) {
const setEditorModeCallBack = editor.switchEditor.bind(editor);
const getEditorModeCallback = () => editor.mode;
const newSpec: typeof spec = [
...spec,
DocModeExtension(
mockDocModeService(getEditorModeCallback, setEditorModeCallBack)
),
OverrideThemeExtension(themeExtension),
ParseDocUrlExtension(mockParseDocUrlService(collection)),
GenerateDocUrlExtension(mockGenerateDocUrlService(collection)),
NotificationExtension(mockNotificationService(editor)),
FontConfigExtension(CommunityCanvasTextFonts),
mockPeekViewExtension(attachmentViewerPanel),
];
return newSpec;
}
}
@@ -0,0 +1,26 @@
function escapeHtml(html: string) {
const div = document.createElement('div');
div.textContent = html;
return div.innerHTML;
}
// Custom function to emit toast notifications
export function notify(
message: string,
variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary',
icon = 'info-circle',
duration = 2000
) {
const alert = Object.assign(document.createElement('sl-alert'), {
variant,
closable: true,
duration: duration,
innerHTML: `
<sl-icon name="${icon}" slot="icon"></sl-icon>
${escapeHtml(message)}
`,
});
document.body.append(alert);
return alert.toast();
}
+60
View File
@@ -0,0 +1,60 @@
import * as globalUtils from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store';
function toStyledEntry(key: string, value: unknown) {
return [
['span', { style: 'color: #c0c0c0' }, ` ${key}`],
['span', { style: 'color: #fff' }, `: `],
['span', { style: 'color: rgb(92, 213, 251)' }, `${JSON.stringify(value)}`],
];
}
export const devtoolsFormatter: typeof window.devtoolsFormatters = [
{
header: function (obj: unknown) {
if ('flavour' in (obj as BlockModel) && 'yBlock' in (obj as BlockModel)) {
globalUtils.assertType<BlockModel>(obj);
return [
'span',
{ style: 'font-weight: bolder;' },
['span', { style: 'color: #fff' }, `Block {`],
...toStyledEntry('flavour', obj.flavour),
['span', { style: 'color: #fff' }, `,`],
...toStyledEntry('id', obj.id),
['span', { style: 'color: #fff' }, `}`],
] as HTMLTemplate;
}
return null;
},
hasBody: (obj: unknown) => {
if ('flavour' in (obj as BlockModel) && 'yBlock' in (obj as BlockModel)) {
return true;
}
return null;
},
body: (obj: unknown) => {
if ('flavour' in (obj as BlockModel) && 'yBlock' in (obj as BlockModel)) {
globalUtils.assertType<BlockModel>(obj);
// @ts-expect-error FIXME: ts error
const { props } = obj.page._blockTree.getBlock(obj.id)._parseYBlock();
const propsArr = Object.entries(props).flatMap(([key]) => {
return [
// @ts-expect-error FIXME: ts error
...toStyledEntry(key, obj[key]),
['div', {}, ''],
] as HTMLTemplate[];
});
return ['div', { style: 'padding-left: 1em' }, ...propsArr];
}
return null;
},
},
];
window.devtoolsFormatters = devtoolsFormatter;
+35
View File
@@ -0,0 +1,35 @@
import type { EditorHost } from '@blocksuite/block-std';
import type { TestUtils } from '@blocksuite/blocks';
import type { AffineEditorContainer } from '@blocksuite/presets';
import type { BlockSchema, Doc, DocCollection, Job } from '@blocksuite/store';
import type { z } from 'zod';
declare global {
type HTMLTemplate = [
string,
Record<string, unknown>,
...(HTMLTemplate | string)[],
];
interface Window {
editor: AffineEditorContainer;
doc: Doc;
collection: DocCollection;
blockSchemas: z.infer<typeof BlockSchema>[];
job: Job;
Y: typeof DocCollection.Y;
std: typeof std;
testUtils: TestUtils;
host: EditorHost;
testWorker: Worker;
wsProvider: ReturnType<typeof setupBroadcastProvider>;
bcProvider: ReturnType<typeof setupBroadcastProvider>;
devtoolsFormatters: {
header: (obj: unknown, config: unknown) => null | HTMLTemplate;
hasBody: (obj: unknown, config: unknown) => boolean | null;
body: (obj: unknown, config: unknown) => null | HTMLTemplate;
}[];
}
}
@@ -0,0 +1,21 @@
import { ZipTransformer } from '@blocksuite/blocks';
import { type DocCollection, Text } from '@blocksuite/store';
export async function affineSnapshot(collection: DocCollection, id: string) {
const doc = collection.createDoc({ id });
doc.load();
// Add root block and surface block at root level
const rootId = doc.addBlock('affine:page', {
title: new Text('Affine Snapshot Test'),
});
doc.addBlock('affine:surface', {}, rootId);
const path = '/apps/starter/data/snapshots/affine-default.zip';
const response = await fetch(path);
const file = await response.blob();
await ZipTransformer.importDocs(collection, file);
}
affineSnapshot.id = 'affine-snapshot';
affineSnapshot.displayName = 'Affine Snapshot Test';
affineSnapshot.description = 'Affine Snapshot Test';
@@ -0,0 +1,160 @@
import {
databaseBlockColumns,
type DatabaseBlockModel,
type ListType,
type ParagraphType,
type ViewBasicDataType,
} from '@blocksuite/blocks';
import { viewPresets } from '@blocksuite/data-view/view-presets';
import { assertExists } from '@blocksuite/global/utils';
import { type DocCollection, Text } from '@blocksuite/store';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { propertyPresets } from '../../../../affine/data-view/src/property-presets';
import type { InitFn } from './utils.js';
export const database: InitFn = (collection: DocCollection, id: string) => {
const doc = collection.createDoc({ id });
doc.awarenessStore.setFlag('enable_database_number_formatting', true);
doc.awarenessStore.setFlag('enable_database_attachment_note', true);
doc.awarenessStore.setFlag('enable_database_full_width', true);
doc.awarenessStore.setFlag('enable_block_query', true);
doc.load(() => {
// Add root block and surface block at root level
const rootId = doc.addBlock('affine:page', {
title: new Text('BlockSuite Playground'),
});
doc.addBlock('affine:surface', {}, rootId);
// Add note block inside root block
const noteId = doc.addBlock('affine:note', {}, rootId);
const pId = doc.addBlock('affine:paragraph', {}, noteId);
const model = doc.getBlockById(pId);
assertExists(model);
const addDatabase = (title: string, group = true) => {
const databaseId = doc.addBlock(
'affine:database',
{
columns: [],
cells: {},
},
noteId
);
new Promise(resolve => requestAnimationFrame(resolve))
.then(() => {
const service = window.host.std.getService('affine:database');
if (!service) return;
service.initDatabaseBlock(
doc,
model,
databaseId,
viewPresets.tableViewMeta.type,
true
);
const database = doc.getBlockById(databaseId) as DatabaseBlockModel;
database.title = new Text(title);
const richTextId = service.addColumn(
database,
'end',
databaseBlockColumns.richTextColumnConfig.create(
databaseBlockColumns.richTextColumnConfig.config.name
)
);
Object.values([
propertyPresets.multiSelectPropertyConfig,
propertyPresets.datePropertyConfig,
propertyPresets.numberPropertyConfig,
databaseBlockColumns.linkColumnConfig,
propertyPresets.checkboxPropertyConfig,
propertyPresets.progressPropertyConfig,
]).forEach(column => {
service.addColumn(
database,
'end',
column.create(column.config.name)
);
});
service.updateView(database, database.views[0].id, () => {
return {
groupBy: group
? {
columnId: database.columns[1].id,
type: 'groupBy',
name: 'select',
}
: undefined,
} as Partial<ViewBasicDataType>;
});
const paragraphTypes: ParagraphType[] = [
'text',
'quote',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
];
paragraphTypes.forEach(type => {
const id = doc.addBlock(
'affine:paragraph',
{ type: type, text: new Text(`Paragraph type ${type}`) },
databaseId
);
service.updateCell(database, id, {
columnId: richTextId,
value: new Text(`Paragraph type ${type}`),
});
});
const listTypes: ListType[] = [
'numbered',
'bulleted',
'todo',
'toggle',
];
listTypes.forEach(type => {
const id = doc.addBlock(
'affine:list',
{ type: type, text: new Text(`List type ${type}`) },
databaseId
);
service.updateCell(database, id, {
columnId: richTextId,
value: new Text(`List type ${type}`),
});
});
// Add a paragraph after database
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
service.databaseViewAddView(
database,
viewPresets.kanbanViewMeta.type
);
doc.resetHistory();
})
.catch(console.error);
};
// Add database block inside note block
addDatabase('Database 1', false);
addDatabase('Database 2');
addDatabase('Database 3');
addDatabase('Database 4');
addDatabase('Database 5');
addDatabase('Database 6');
addDatabase('Database 7');
addDatabase('Database 8');
addDatabase('Database 9');
addDatabase('Database 10');
});
};
database.id = 'database';
database.displayName = 'Database Example';
database.description = 'Database block basic example';
@@ -0,0 +1,54 @@
import { type DocCollection, Text } from '@blocksuite/store';
import type { InitFn } from './utils.js';
export const embed: InitFn = (collection: DocCollection, id: string) => {
const doc = collection.getDoc(id) ?? collection.createDoc({ id });
doc.clear();
doc.load(() => {
// Add root block and surface block at root level
const rootId = doc.addBlock('affine:page', {
title: new Text(),
});
const surfaceId = doc.addBlock('affine:surface', {}, rootId);
// Add note block inside root block
const noteId = doc.addBlock('affine:note', {}, rootId);
// Add paragraph block inside note block
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock(
'affine:embed-github',
{
url: 'https://github.com/toeverything/AFFiNE/pull/5453',
},
noteId
);
doc.addBlock(
'affine:embed-github',
{
url: 'https://www.github.com/toeverything/blocksuite/pull/5927',
style: 'vertical',
xywh: '[0, 400, 364, 390]',
},
surfaceId
);
doc.addBlock(
'affine:embed-github',
{
url: 'https://github.com/Milkdown/milkdown/pull/1215',
xywh: '[500, 400, 752, 116]',
},
surfaceId
);
doc.addBlock('affine:paragraph', {}, noteId);
});
doc.resetHistory();
};
embed.id = 'embed';
embed.displayName = 'Example for embed blocks';
embed.description = 'Example for embed blocks';
@@ -0,0 +1,28 @@
import { type DocCollection, Text } from '@blocksuite/store';
import type { InitFn } from './utils.js';
export const empty: InitFn = (collection: DocCollection, id: string) => {
const doc = collection.getDoc(id) ?? collection.createDoc({ id });
doc.clear();
doc.load(() => {
// Add root block and surface block at root level
const rootId = doc.addBlock('affine:page', {
title: new Text(),
});
doc.addBlock('affine:surface', {}, rootId);
// Add note block inside root block
const noteId = doc.addBlock('affine:note', {}, rootId);
// Add paragraph block inside note block
doc.addBlock('affine:paragraph', {}, noteId);
});
doc.resetHistory();
};
empty.id = 'empty';
empty.displayName = 'Empty Editor';
empty.description = 'Start from empty editor';
@@ -0,0 +1,101 @@
import { DEFAULT_ROUGHNESS } from '@blocksuite/affine-model';
import type { SerializedXYWH } from '@blocksuite/global/utils';
import {
Boxed,
type DocCollection,
nanoid,
native2Y,
Text,
type Y,
} from '@blocksuite/store';
import type { InitFn } from './utils.js';
const SHAPE_TYPES = ['rect', 'triangle', 'ellipse', 'diamond'];
const params = new URLSearchParams(location.search);
function createShapes(count: number): Record<string, unknown> {
const surfaceBlocks: Record<string, unknown> = {};
for (let i = 0; i < count; i++) {
const x = Math.random() * count * 2;
const y = Math.random() * count * 2;
const id = nanoid();
surfaceBlocks[id] = native2Y(
{
id,
index: 'a0',
type: 'shape',
xywh: `[${x},${y},100,100]`,
seed: Math.floor(Math.random() * 2 ** 31),
shapeType: SHAPE_TYPES[Math.floor(Math.random() * 40) % 4],
radius: 0,
filled: false,
fillColor: '--affine-palette-shape-yellow',
strokeWidth: 4,
strokeColor: '--affine-palette-line-yellow',
strokeStyle: 'solid',
roughness: DEFAULT_ROUGHNESS,
},
{ deep: false }
);
}
return surfaceBlocks;
}
const SHAPES_COUNT = 100;
const RANGE = 2000;
export const heavyWhiteboard: InitFn = (
collection: DocCollection,
id: string
) => {
const count = Number(params.get('count')) || SHAPES_COUNT;
const enableShapes = !!params.get('shapes');
const doc = collection.createDoc({ id });
doc.load(() => {
// Add root block and surface block at root level
const rootId = doc.addBlock('affine:page', {
title: new Text(),
});
const surfaceBlocks = enableShapes ? createShapes(count) : {};
doc.addBlock(
'affine:surface',
{
elements: new Boxed(native2Y(surfaceBlocks, { deep: false })) as Boxed<
Y.Map<Y.Map<unknown>>
>,
},
rootId
);
let i = 0;
// Add note block inside root block
for (i = 0; i < count; i++) {
const x = Math.random() * RANGE - RANGE / 2;
const y = Math.random() * RANGE - RANGE / 2;
const noteId = doc.addBlock(
'affine:note',
{
xywh: `[${x}, ${y}, 100, 50]` as SerializedXYWH,
},
rootId
);
// Add paragraph block inside note block
doc.addBlock(
'affine:paragraph',
{
text: new Text('Note #' + i),
},
noteId
);
}
});
};
heavyWhiteboard.id = 'heavy-whiteboard';
heavyWhiteboard.displayName = 'Heavy Whiteboard';
heavyWhiteboard.description = 'Heavy Whiteboard on 200 elements by default';
@@ -0,0 +1,35 @@
import { type DocCollection, Text } from '@blocksuite/store';
import type { InitFn } from './utils.js';
const params = new URLSearchParams(location.search);
export const heavy: InitFn = (collection: DocCollection, docId: string) => {
const count = Number(params.get('count')) || 1000;
const doc = collection.createDoc({ id: docId });
doc.load(() => {
// Add root block and surface block at root level
const rootId = doc.addBlock('affine:page', {
title: new Text(),
});
doc.addBlock('affine:surface', {}, rootId);
// Add note block inside root block
const noteId = doc.addBlock('affine:note', {}, rootId);
for (let i = 0; i < count; i++) {
// Add paragraph block inside note block
doc.addBlock(
'affine:paragraph',
{
text: new Text('Hello, world! ' + i),
},
noteId
);
}
});
};
heavy.id = 'heavy';
heavy.displayName = 'Heavy Example';
heavy.description = 'Heavy example on thousands of paragraph blocks';
@@ -0,0 +1,19 @@
/**
* Manually create initial page structure.
* In collaboration mode or on page refresh with local persistence,
* the page structure will be automatically loaded from provider.
* In these cases, these functions should not be called.
*/
export * from './affine-snapshot.js';
export * from './database.js';
export * from './embed.js';
export * from './empty.js';
export * from './heavy.js';
export * from './heavy-whiteboard.js';
export * from './linked.js';
export * from './multiple-editor.js';
export * from './pending-structs.js';
export * from './preset.js';
export * from './synced.js';
export type { InitFn } from './utils.js';
export * from './version-mismatch.js';
@@ -0,0 +1,80 @@
import { type DocCollection, Text } from '@blocksuite/store';
import type { InitFn } from './utils.js';
export const linked: InitFn = (collection: DocCollection, id: string) => {
const docA = collection.getDoc(id) ?? collection.createDoc({ id });
const docBId = 'doc:linked-page';
const docB = collection.createDoc({ id: docBId });
const docCId = 'doc:linked-edgeless';
const docC = collection.createDoc({ id: docCId });
docA.clear();
docB.clear();
docC.clear();
docB.load(() => {
const rootId = docB.addBlock('affine:page', {
title: new Text(''),
});
docB.addBlock('affine:surface', {}, rootId);
// Add note block inside root block
const noteId = docB.addBlock('affine:note', {}, rootId);
// Add paragraph block inside note block
docB.addBlock('affine:paragraph', {}, noteId);
});
docC.load(() => {
const rootId = docC.addBlock('affine:page', {
title: new Text(''),
});
docC.addBlock('affine:surface', {}, rootId);
// Add note block inside root block
const noteId = docC.addBlock('affine:note', {}, rootId);
// Add paragraph block inside note block
docC.addBlock('affine:paragraph', {}, noteId);
});
docA.load();
// Add root block and surface block at root level
const rootId = docA.addBlock('affine:page', {
title: new Text('Doc A'),
});
docA.addBlock('affine:surface', {}, rootId);
// Add note block inside root block
const noteId = docA.addBlock('affine:note', {}, rootId);
// Add paragraph block inside note block
docA.addBlock('affine:paragraph', {}, noteId);
docA.addBlock('affine:embed-linked-doc', { pageId: docBId }, noteId);
docA.addBlock(
'affine:embed-linked-doc',
{ pageId: 'doc:deleted-example' },
noteId
);
docA.addBlock('affine:embed-linked-doc', { pageId: docCId }, noteId);
docA.addBlock(
'affine:embed-linked-doc',
{ pageId: 'doc:deleted-example-edgeless' },
noteId
);
docA.resetHistory();
docB.resetHistory();
docC.resetHistory();
};
linked.id = 'linked';
linked.displayName = 'Linked Doc Editor';
linked.description = 'A demo with linked docs';
@@ -0,0 +1,95 @@
import { RefNodeSlotsProvider } from '@blocksuite/affine-components/rich-text';
import { AffineEditorContainer } from '@blocksuite/presets';
import { type DocCollection, Text } from '@blocksuite/store';
import type { InitFn } from './utils.js';
export const multiEditor: InitFn = (collection: DocCollection, id: string) => {
const doc = collection.createDoc({ id });
doc.load(() => {
// Add root block and surface block at root level
const rootId = doc.addBlock('affine:page', {
title: new Text(),
});
doc.addBlock('affine:surface', {}, rootId);
// Add note block inside root block
const noteId = doc.addBlock('affine:note', {}, rootId);
// Add paragraph block inside note block
doc.addBlock('affine:paragraph', {}, noteId);
});
doc.resetHistory();
const app = document.getElementById('app');
if (app) {
const editor = new AffineEditorContainer();
editor.doc = doc;
editor.std
.get(RefNodeSlotsProvider)
.docLinkClicked.on(({ pageId: docId }) => {
const target = collection.getDoc(docId);
if (!target) {
throw new Error(`Failed to jump to doc ${docId}`);
}
target.load();
editor.doc = target;
});
editor.style.borderRight = '1px solid var(--affine-border-color)';
app.append(editor);
app.style.display = 'flex';
}
};
multiEditor.id = 'multiple-editor';
multiEditor.displayName = 'Multiple Editor Example';
multiEditor.description = 'Multiple Editor basic example';
export const multiEditorVertical: InitFn = (
collection: DocCollection,
docId: string
) => {
const doc = collection.createDoc({ id: docId });
doc.load(() => {
// Add root block and surface block at root level
const rootId = doc.addBlock('affine:page', {
title: new Text(),
});
doc.addBlock('affine:surface', {}, rootId);
// Add note block inside root block
const noteId = doc.addBlock('affine:note', {}, rootId);
// Add paragraph block inside note block
doc.addBlock('affine:paragraph', {}, noteId);
});
doc.resetHistory();
const app = document.getElementById('app');
if (app) {
const editor = new AffineEditorContainer();
editor.doc = doc;
editor.std
.get(RefNodeSlotsProvider)
.docLinkClicked.on(({ pageId: docId }) => {
const target = collection.getDoc(docId);
if (!target) {
throw new Error(`Failed to jump to doc ${docId}`);
}
target.load();
editor.doc = target;
});
editor.style.borderBottom = '1px solid var(--affine-border-color)';
app.append(editor);
app.style.display = 'flex';
app.style.flexDirection = 'column';
}
};
multiEditorVertical.id = 'multiple-editor-vertical';
multiEditorVertical.displayName = 'Vertical Multiple Editor Example';
multiEditorVertical.description = 'Multiple Editor vertical layout example';
@@ -0,0 +1,41 @@
import { DocCollection, Text } from '@blocksuite/store';
import type { InitFn } from './utils.js';
export const pendingStructs: InitFn = (
collection: DocCollection,
id: string
) => {
const doc = collection.createDoc({ id });
const tempDoc = collection.createDoc({ id: 'tempDoc' });
doc.load();
tempDoc.load(() => {
const rootId = tempDoc.addBlock('affine:page', {
title: new Text('Pending Structs'),
});
const vec = DocCollection.Y.encodeStateVector(tempDoc.spaceDoc);
// To avoid pending structs, uncomment the following line
// const update = DocCollection.Y.encodeStateAsUpdate(tempDoc.spaceDoc);
tempDoc.addBlock('affine:surface', {}, rootId);
// Add note block inside root block
const noteId = tempDoc.addBlock('affine:note', {}, rootId);
tempDoc.addBlock(
'affine:paragraph',
{
text: new Text('This is a paragraph block'),
},
noteId
);
const diff = DocCollection.Y.encodeStateAsUpdate(tempDoc.spaceDoc, vec);
// To avoid pending structs, uncomment the following line
// DocCollection.Y.applyUpdate(doc.spaceDoc, update);
DocCollection.Y.applyUpdate(doc.spaceDoc, diff);
});
};
pendingStructs.id = 'pending-structs';
pendingStructs.displayName = 'Pending Structs';
pendingStructs.description = 'Doc with pending structs';
@@ -0,0 +1,36 @@
import { MarkdownTransformer } from '@blocksuite/blocks';
import { type DocCollection, Text } from '@blocksuite/store';
import type { InitFn } from './utils.js';
const presetMarkdown = `Click the 🔁 button to switch between editors dynamically - they are fully compatible!`;
export const preset: InitFn = async (collection: DocCollection, id: string) => {
const doc = collection.createDoc({ id });
doc.load();
// Add root block and surface block at root level
const rootId = doc.addBlock('affine:page', {
title: new Text('BlockSuite Playground'),
});
doc.addBlock('affine:surface', {}, rootId);
// Add note block inside root block
const noteId = doc.addBlock(
'affine:note',
{ xywh: '[0, 100, 800, 640]' },
rootId
);
// Import preset markdown content inside note block
await MarkdownTransformer.importMarkdownToBlock({
doc,
blockId: noteId,
markdown: presetMarkdown,
});
doc.resetHistory();
};
preset.id = 'preset';
preset.displayName = 'BlockSuite Starter';
preset.description = 'Start from friendly introduction';
@@ -0,0 +1,167 @@
import { MarkdownTransformer } from '@blocksuite/blocks';
import { type DocCollection, Text } from '@blocksuite/store';
import type { InitFn } from './utils';
const syncedDocMarkdown = `We share some of our findings from developing local-first software prototypes at [Ink & Switch](https://www.inkandswitch.com/) over the course of several years. These experiments test the viability of CRDTs in practice, and explore the user interface challenges for this new data model. Lastly, we suggest some next steps for moving towards local-first software: for researchers, for app developers, and a startup opportunity for entrepreneurs.
This article has also been published [in PDF format](https://www.inkandswitch.com/local-first/static/local-first.pdf) in the proceedings of the [Onward! 2019 conference](https://2019.splashcon.org/track/splash-2019-Onward-Essays). Please cite it as:
> Martin Kleppmann, Adam Wiggins, Peter van Hardenberg, and Mark McGranaghan. Local-first software: you own your data, in spite of the cloud. 2019 ACM SIGPLAN International Symposium on New Ideas, New Paradigms, and Reflections on Programming and Software (Onward!), October 2019, pages 154-178. [doi:10.1145/3359591.3359737](https://doi.org/10.1145/3359591.3359737)
We welcome your feedback: [@inkandswitch](https://twitter.com/inkandswitch) or hello@inkandswitch.com.`;
export const synced: InitFn = (collection: DocCollection, id: string) => {
const docMain = collection.getDoc(id) ?? collection.createDoc({ id });
const docSyncedPageId = 'doc:synced-page';
const docSyncedPage = collection.createDoc({ id: docSyncedPageId });
const docSyncedEdgelessId = 'doc:synced-edgeless';
const docSyncedEdgeless = collection.createDoc({ id: docSyncedEdgelessId });
docMain.clear();
docSyncedPage.clear();
docSyncedEdgeless.clear();
docSyncedPage.load(() => {
// Add root block and surface block at root level
const rootId = docSyncedPage.addBlock('affine:page', {
title: new Text('Synced - Page View'),
});
docSyncedPage.addBlock('affine:surface', {}, rootId);
// Add note block inside root block
const noteId = docSyncedPage.addBlock('affine:note', {}, rootId);
// Add markdown to note block
MarkdownTransformer.importMarkdownToBlock({
doc: docSyncedPage,
blockId: noteId,
markdown: syncedDocMarkdown,
}).catch(console.error);
});
docSyncedEdgeless.load(() => {
// Add root block and surface block at root level
const rootId = docSyncedEdgeless.addBlock('affine:page', {
title: new Text('Synced - Edgeless View'),
});
docSyncedEdgeless.addBlock('affine:surface', {}, rootId);
// Add note block inside root block
const noteId = docSyncedEdgeless.addBlock('affine:note', {}, rootId);
// Add markdown to note block
MarkdownTransformer.importMarkdownToBlock({
doc: docSyncedEdgeless,
blockId: noteId,
markdown: syncedDocMarkdown,
}).catch(console.error);
});
docMain.load(() => {
// Add root block and surface block at root level
const rootId = docMain.addBlock('affine:page', {
title: new Text('Home doc, having synced blocks'),
});
const surfaceId = docMain.addBlock('affine:surface', {}, rootId);
const noteId = docMain.addBlock('affine:note', {}, rootId);
// Add markdown to note block
MarkdownTransformer.importMarkdownToBlock({
doc: docMain,
blockId: noteId,
markdown: syncedDocMarkdown,
})
.then(() => {
// Add synced block - self
docMain.addBlock(
'affine:paragraph',
{
text: new Text('Cyclic / Matryoshka synced block 👇'),
type: 'h4',
},
noteId
);
// Add synced block - self
docMain.addBlock(
'affine:embed-synced-doc',
{
pageId: id,
},
noteId
);
// Add synced block - page view
docMain.addBlock(
'affine:embed-synced-doc',
{
pageId: docSyncedPageId,
},
noteId
);
// Add synced block - edgeless view
docMain.addBlock(
'affine:embed-synced-doc',
{
pageId: docSyncedEdgelessId,
},
noteId
);
// Add synced block - page view
docMain.addBlock(
'affine:embed-synced-doc',
{
pageId: docSyncedPageId,
xywh: '[-1000, 0, 752, 455]',
},
surfaceId
);
// Add synced block - edgeless view
docMain.addBlock(
'affine:embed-synced-doc',
{
pageId: docSyncedEdgelessId,
xywh: '[-1000, 500, 752, 455]',
},
surfaceId
);
// Add synced block - self
docMain.addBlock(
'affine:embed-synced-doc',
{
pageId: id,
xywh: '[-1000, 1000, 752, 455]',
},
surfaceId
);
// Add synced block - self
docMain.addBlock(
'affine:embed-synced-doc',
{
pageId: 'doc:deleted-page',
},
noteId
);
})
.catch(console.error);
});
docSyncedEdgeless.resetHistory();
docSyncedPage.resetHistory();
docMain.resetHistory();
};
synced.id = 'synced';
synced.displayName = 'Synced block demo';
synced.description = 'A simple demo for synced block';
@@ -0,0 +1,8 @@
import type { DocCollection } from '@blocksuite/store';
export interface InitFn {
(collection: DocCollection, docId: string): Promise<void> | void;
id: string;
displayName: string;
description: string;
}
@@ -0,0 +1,39 @@
import type { Y } from '@blocksuite/store';
import { DocCollection } from '@blocksuite/store';
import type { InitFn } from './utils.js';
export const versionMismatch: InitFn = (
collection: DocCollection,
id: string
) => {
const doc = collection.createDoc({ id });
const tempDoc = collection.createDoc({ id: 'tempDoc' });
doc.load();
tempDoc.load(() => {
const rootId = tempDoc.addBlock('affine:page', {});
tempDoc.addBlock('affine:surface', {}, rootId);
const noteId = tempDoc.addBlock(
'affine:note',
{ xywh: '[0, 100, 800, 640]' },
rootId
);
const paragraphId = tempDoc.addBlock('affine:paragraph', {}, noteId);
const blocks = tempDoc.spaceDoc.get('blocks') as Y.Map<unknown>;
const paragraph = blocks.get(paragraphId) as Y.Map<unknown>;
paragraph.set('sys:version', (paragraph.get('sys:version') as number) + 1);
const update = DocCollection.Y.encodeStateAsUpdate(tempDoc.spaceDoc);
DocCollection.Y.applyUpdate(doc.spaceDoc, update);
doc.addBlock('affine:paragraph', {}, noteId);
});
collection.removeDoc('tempDoc');
doc.resetHistory();
};
versionMismatch.id = 'version-mismatch';
versionMismatch.displayName = 'Version Mismatch';
versionMismatch.description = 'Error boundary when version mismatch in data';
@@ -0,0 +1,91 @@
import '../../style.css';
import '../dev-format.js';
import {
type ExtensionType,
WidgetViewMapExtension,
WidgetViewMapIdentifier,
} from '@blocksuite/block-std';
import * as blocks from '@blocksuite/blocks';
import {
CommunityCanvasTextFonts,
DocModeProvider,
FontConfigExtension,
ParseDocUrlProvider,
QuickSearchProvider,
RefNodeSlotsExtension,
RefNodeSlotsProvider,
} from '@blocksuite/blocks';
import { effects as blocksEffects } from '@blocksuite/blocks/effects';
import * as globalUtils from '@blocksuite/global/utils';
import * as editor from '@blocksuite/presets';
import { effects as presetsEffects } from '@blocksuite/presets/effects';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import * as store from '@blocksuite/store';
import { mockDocModeService } from '../_common/mock-services.js';
import { setupEdgelessTemplate } from '../_common/setup.js';
import {
createStarterDocCollection,
initStarterDocCollection,
} from './utils/collection.js';
import { mountDefaultDocEditor } from './utils/editor.js';
blocksEffects();
presetsEffects();
async function main() {
if (window.collection) return;
setupEdgelessTemplate();
const params = new URLSearchParams(location.search);
const room = params.get('room') ?? Math.random().toString(16).slice(2, 8);
const isE2E = room.startsWith('playwright');
const collection = createStarterDocCollection();
if (isE2E) {
Object.defineProperty(window, '$blocksuite', {
value: Object.freeze({
store,
blocks,
global: { utils: globalUtils },
editor,
identifiers: {
WidgetViewMapIdentifier,
QuickSearchProvider,
DocModeProvider,
RefNodeSlotsProvider,
ParseDocUrlService: ParseDocUrlProvider,
},
defaultExtensions: (): ExtensionType[] => [
FontConfigExtension(CommunityCanvasTextFonts),
RefNodeSlotsExtension(),
],
extensions: {
FontConfigExtension: FontConfigExtension(CommunityCanvasTextFonts),
WidgetViewMapExtension,
RefNodeSlotsExtension: RefNodeSlotsExtension(),
},
mockServices: {
mockDocModeService,
},
}),
});
// test if blocksuite can run in a web worker, SEE: tests/worker.spec.ts
// window.testWorker = new Worker(
// new URL('./utils/test-worker.ts', import.meta.url),
// {
// type: 'module',
// }
// );
return;
}
await initStarterDocCollection(collection);
await mountDefaultDocEditor(collection);
}
main().catch(console.error);
@@ -0,0 +1,138 @@
import { AffineSchemas, TestUtils } from '@blocksuite/blocks';
import type { BlockSuiteFlags } from '@blocksuite/global/types';
import { assertExists } from '@blocksuite/global/utils';
import {
type BlockCollection,
DocCollection,
type DocCollectionOptions,
IdGeneratorType,
Job,
Schema,
} from '@blocksuite/store';
import {
type BlobSource,
BroadcastChannelAwarenessSource,
BroadcastChannelDocSource,
IndexedDBBlobSource,
MemoryBlobSource,
} from '@blocksuite/sync';
import { MockServerBlobSource } from '../../_common/sync/blob/mock-server.js';
import type { InitFn } from '../data/utils.js';
const params = new URLSearchParams(location.search);
const room = params.get('room');
const isE2E = room?.startsWith('playwright');
const blobSourceArgs = (params.get('blobSource') ?? '').split(',');
export function createStarterDocCollection() {
const collectionId = room ?? 'starter';
const schema = new Schema();
schema.register(AffineSchemas);
const idGenerator = isE2E
? IdGeneratorType.AutoIncrement
: IdGeneratorType.NanoID;
let docSources: DocCollectionOptions['docSources'];
if (room) {
docSources = {
main: new BroadcastChannelDocSource(`broadcast-channel-${room}`),
};
}
const id = room ?? `starter-${Math.random().toString(16).slice(2, 8)}`;
const blobSources = {
main: new MemoryBlobSource(),
shadows: [] as BlobSource[],
} satisfies DocCollectionOptions['blobSources'];
if (blobSourceArgs.includes('mock')) {
blobSources.shadows.push(new MockServerBlobSource(collectionId));
}
if (blobSourceArgs.includes('idb')) {
blobSources.shadows.push(new IndexedDBBlobSource(collectionId));
}
const flags: Partial<BlockSuiteFlags> = Object.fromEntries(
[...params.entries()]
.filter(([key]) => key.startsWith('enable_'))
.map(([k, v]) => [k, v === 'true'])
);
const options: DocCollectionOptions = {
id: collectionId,
schema,
idGenerator,
defaultFlags: {
enable_synced_doc_block: true,
enable_pie_menu: true,
enable_lasso_tool: true,
enable_edgeless_text: true,
enable_color_picker: true,
enable_mind_map_import: true,
enable_advanced_block_visibility: true,
enable_shape_shadow_blur: false,
...flags,
},
awarenessSources: [new BroadcastChannelAwarenessSource(id)],
docSources,
blobSources,
};
const collection = new DocCollection(options);
collection.start();
// debug info
window.collection = collection;
window.blockSchemas = AffineSchemas;
window.job = new Job({ collection: collection });
window.Y = DocCollection.Y;
window.testUtils = new TestUtils();
return collection;
}
export async function initStarterDocCollection(collection: DocCollection) {
// init from other clients
if (room && !params.has('init')) {
const firstCollection = collection.docs.values().next().value as
| BlockCollection
| undefined;
let firstDoc = firstCollection?.getDoc();
if (!firstDoc) {
await new Promise<string>(resolve =>
collection.slots.docAdded.once(resolve)
);
const firstCollection = collection.docs.values().next().value as
| BlockCollection
| undefined;
firstDoc = firstCollection?.getDoc();
}
assertExists(firstDoc);
const doc = firstDoc;
doc.load();
if (!doc.root) {
await new Promise(resolve => doc.slots.rootAdded.once(resolve));
}
doc.resetHistory();
return;
}
// use built-in init function
const functionMap = new Map<
string,
(collection: DocCollection, id: string) => Promise<void> | void
>();
Object.values(
(await import('../data/index.js')) as Record<string, InitFn>
).forEach(fn => functionMap.set(fn.id, fn));
const init = params.get('init') || 'preset';
if (functionMap.has(init)) {
collection.meta.initialize();
await functionMap.get(init)?.(collection, 'doc:home');
const doc = collection.getDoc('doc:home');
if (!doc?.loaded) {
doc?.load();
}
doc?.resetHistory();
}
}
@@ -0,0 +1,203 @@
import {
BlockServiceWatcher,
type EditorHost,
type ExtensionType,
} from '@blocksuite/block-std';
import {
AffineFormatBarWidget,
CommunityCanvasTextFonts,
DocModeProvider,
FontConfigExtension,
GenerateDocUrlExtension,
NotificationExtension,
OverrideThemeExtension,
type PageRootService,
ParseDocUrlExtension,
RefNodeSlotsExtension,
RefNodeSlotsProvider,
SpecProvider,
toolbarDefaultConfig,
} from '@blocksuite/blocks';
import { AffineEditorContainer, CommentPanel } from '@blocksuite/presets';
import type { DocCollection } from '@blocksuite/store';
import { AttachmentViewerPanel } from '../../_common/components/attachment-viewer-panel.js';
import { CustomFramePanel } from '../../_common/components/custom-frame-panel.js';
import { CustomOutlinePanel } from '../../_common/components/custom-outline-panel.js';
import { CustomOutlineViewer } from '../../_common/components/custom-outline-viewer.js';
import { DocsPanel } from '../../_common/components/docs-panel.js';
import { LeftSidePanel } from '../../_common/components/left-side-panel.js';
import { SidePanel } from '../../_common/components/side-panel.js';
import { StarterDebugMenu } from '../../_common/components/starter-debug-menu.js';
import {
getDocFromUrlParams,
listenHashChange,
setDocModeFromUrlParams,
} from '../../_common/history.js';
import {
mockDocModeService,
mockGenerateDocUrlService,
mockNotificationService,
mockParseDocUrlService,
themeExtension,
} from '../../_common/mock-services';
function configureFormatBar(formatBar: AffineFormatBarWidget) {
toolbarDefaultConfig(formatBar);
}
export async function mountDefaultDocEditor(collection: DocCollection) {
const app = document.getElementById('app');
if (!app) return;
const url = new URL(location.toString());
const doc = getDocFromUrlParams(collection, url);
const attachmentViewerPanel = new AttachmentViewerPanel();
const editor = new AffineEditorContainer();
class PatchPageServiceWatcher extends BlockServiceWatcher {
static override readonly flavour = 'affine:page';
override mounted() {
const pageRootService = this.blockService as PageRootService;
const onFormatBarConnected = pageRootService.specSlots.widgetConnected.on(
view => {
if (view.component instanceof AffineFormatBarWidget) {
configureFormatBar(view.component);
}
}
);
pageRootService.disposables.add(onFormatBarConnected);
}
}
const refNodeSlotsExtension = RefNodeSlotsExtension();
const extensions: ExtensionType[] = [
refNodeSlotsExtension,
PatchPageServiceWatcher,
FontConfigExtension(CommunityCanvasTextFonts),
ParseDocUrlExtension(mockParseDocUrlService(collection)),
GenerateDocUrlExtension(mockGenerateDocUrlService(collection)),
NotificationExtension(mockNotificationService(editor)),
OverrideThemeExtension(themeExtension),
{
setup: di => {
di.override(DocModeProvider, () =>
mockDocModeService(getEditorModeCallback, setEditorModeCallBack)
);
},
},
// mockPeekViewExtension(attachmentViewerPanel),
];
const pageSpecs = SpecProvider.getInstance().getSpec('page');
const setEditorModeCallBack = editor.switchEditor.bind(editor);
const getEditorModeCallback = () => editor.mode;
pageSpecs.extend([...extensions]);
editor.pageSpecs = pageSpecs.value;
const edgelessSpecs = SpecProvider.getInstance().getSpec('edgeless');
edgelessSpecs.extend([...extensions]);
editor.edgelessSpecs = edgelessSpecs.value;
SpecProvider.getInstance().extendSpec('edgeless:preview', [
OverrideThemeExtension(themeExtension),
]);
editor.mode = 'page';
editor.doc = doc;
editor.std
.get(RefNodeSlotsProvider)
.docLinkClicked.on(({ pageId: docId }) => {
const target = collection.getDoc(docId);
if (!target) {
throw new Error(`Failed to jump to doc ${docId}`);
}
target.load();
editor.doc = target;
});
app.append(editor);
await editor.updateComplete;
const modeService = editor.std.provider.get(DocModeProvider);
editor.mode = modeService.getPrimaryMode(doc.id);
setDocModeFromUrlParams(modeService, url.searchParams, doc.id);
editor.slots.docUpdated.on(({ newDocId }) => {
editor.mode = modeService.getPrimaryMode(newDocId);
});
const outlinePanel = new CustomOutlinePanel();
outlinePanel.editor = editor;
const outlineViewer = new CustomOutlineViewer();
outlineViewer.editor = editor;
outlineViewer.toggleOutlinePanel = () => {
outlinePanel.toggleDisplay();
};
const framePanel = new CustomFramePanel();
framePanel.editor = editor;
const sidePanel = new SidePanel();
const leftSidePanel = new LeftSidePanel();
const docsPanel = new DocsPanel();
docsPanel.editor = editor;
const commentPanel = new CommentPanel();
commentPanel.editor = editor;
const debugMenu = new StarterDebugMenu();
debugMenu.collection = collection;
debugMenu.editor = editor;
debugMenu.outlinePanel = outlinePanel;
debugMenu.outlineViewer = outlineViewer;
debugMenu.framePanel = framePanel;
debugMenu.sidePanel = sidePanel;
debugMenu.leftSidePanel = leftSidePanel;
debugMenu.docsPanel = docsPanel;
debugMenu.commentPanel = commentPanel;
document.body.append(attachmentViewerPanel);
document.body.append(outlinePanel);
document.body.append(outlineViewer);
document.body.append(framePanel);
document.body.append(sidePanel);
document.body.append(leftSidePanel);
document.body.append(debugMenu);
// for multiple editor
const params = new URLSearchParams(location.search);
const init = params.get('init');
if (init && init.startsWith('multiple-editor')) {
app.childNodes.forEach(node => {
if (node instanceof AffineEditorContainer) {
node.style.flex = '1';
if (init === 'multiple-editor-vertical') {
node.style.overflow = 'auto';
}
}
});
}
// debug info
window.editor = editor;
window.doc = doc;
Object.defineProperty(globalThis, 'host', {
get() {
return document.querySelector<EditorHost>('editor-host');
},
});
Object.defineProperty(globalThis, 'std', {
get() {
return document.querySelector<EditorHost>('editor-host')?.std;
},
});
listenHashChange(collection, editor, docsPanel);
return editor;
}
@@ -0,0 +1,12 @@
// This file is used to test blocksuite can run in a web worker. SEE: tests/worker.spec.ts
import '@blocksuite/store';
// import '@blocksuite/block-std'; // seems not working
import '@blocksuite/blocks/schemas';
globalThis.onmessage = event => {
const { data } = event;
if (data === 'ping') {
postMessage('pong');
}
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />