mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
634 lines
20 KiB
TypeScript
634 lines
20 KiB
TypeScript
/* 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 readonly _darkModeChange = (e: MediaQueryListEvent) => {
|
|
this._setThemeMode(!!e.matches);
|
|
};
|
|
|
|
private readonly _handleDocsPanelClose = () => {
|
|
this.leftSidePanel.toggle(this.docsPanel);
|
|
};
|
|
|
|
private readonly _keydown = (e: KeyboardEvent) => {
|
|
if (e.key === 'F1') {
|
|
this._switchEditorMode();
|
|
}
|
|
};
|
|
|
|
private readonly _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;
|
|
}
|
|
}
|