mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 14:28:51 +08:00
feat(editor): extract linked doc widget package (#11589)
Close [BS-2738](https://github.com/toeverything/AFFiNE/pull/11589)
This commit is contained in:
@@ -1,17 +1,5 @@
|
||||
export { AffineEdgelessZoomToolbarWidget } from './edgeless-zoom-toolbar/index.js';
|
||||
export * from './keyboard-toolbar/index.js';
|
||||
export {
|
||||
type LinkedMenuAction,
|
||||
type LinkedMenuGroup,
|
||||
type LinkedMenuItem,
|
||||
type LinkedWidgetConfig,
|
||||
LinkedWidgetUtils,
|
||||
} from './linked-doc/config.js';
|
||||
export {
|
||||
// It's used in the AFFiNE!
|
||||
showImportModal,
|
||||
} from './linked-doc/import-doc/index.js';
|
||||
export { AffineLinkedDocWidget } from './linked-doc/index.js';
|
||||
export { AffineModalWidget } from './modal/modal.js';
|
||||
export { AffinePageDraggingAreaWidget } from './page-dragging-area/page-dragging-area.js';
|
||||
export * from './viewport-overlay/viewport-overlay.js';
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
openFileOrFiles,
|
||||
type Signal,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { AffineLinkedDocWidget } from '@blocksuite/affine-widget-linked-doc';
|
||||
import { viewPresets } from '@blocksuite/data-view/view-presets';
|
||||
import { assertType } from '@blocksuite/global/utils';
|
||||
import {
|
||||
@@ -105,7 +106,6 @@ import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import type { PageRootBlockComponent } from '../../page/page-root-block.js';
|
||||
import type { AffineLinkedDocWidget } from '../linked-doc/index.js';
|
||||
import {
|
||||
FigmaDuotoneIcon,
|
||||
HeadingIcon,
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
import {
|
||||
ImportIcon,
|
||||
LinkedDocIcon,
|
||||
LinkedEdgelessIcon,
|
||||
NewDocIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { insertLinkedNode } from '@blocksuite/affine-inline-reference';
|
||||
import {
|
||||
DocModeProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
createDefaultDoc,
|
||||
isFuzzyMatch,
|
||||
type Signal,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockStdScope, EditorHost } from '@blocksuite/std';
|
||||
import type { InlineRange } from '@blocksuite/std/inline';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import { showImportModal } from './import-doc/index.js';
|
||||
|
||||
export interface LinkedWidgetConfig {
|
||||
/**
|
||||
* The first item of the trigger keys will be the primary key
|
||||
* e.g. @, [[
|
||||
*/
|
||||
triggerKeys: [string, ...string[]];
|
||||
/**
|
||||
* Convert trigger key to primary key (the first item of the trigger keys)
|
||||
* [[ -> @
|
||||
*/
|
||||
convertTriggerKey: boolean;
|
||||
ignoreBlockTypes: string[];
|
||||
ignoreSelector: string;
|
||||
getMenus: (
|
||||
query: string,
|
||||
abort: () => void,
|
||||
editorHost: EditorHost,
|
||||
inlineEditor: AffineInlineEditor,
|
||||
abortSignal: AbortSignal
|
||||
) => Promise<LinkedMenuGroup[]> | LinkedMenuGroup[];
|
||||
|
||||
/**
|
||||
* Auto focused item
|
||||
*
|
||||
* Will be called when the menu is
|
||||
* - opened
|
||||
* - query changed
|
||||
* - menu group or its items changed
|
||||
*
|
||||
* If the return value is not null, no action will be taken.
|
||||
*/
|
||||
autoFocusedItemKey?: (
|
||||
menus: LinkedMenuGroup[],
|
||||
query: string,
|
||||
currentActiveKey: string | null,
|
||||
editorHost: EditorHost,
|
||||
inlineEditor: AffineInlineEditor
|
||||
) => string | null;
|
||||
|
||||
mobile: {
|
||||
/**
|
||||
* The linked doc menu widget will scroll the container to make sure the input cursor is visible in viewport.
|
||||
* It accepts a selector string, HTMLElement or Window
|
||||
*
|
||||
* @default getViewportElement(editorHost) this is the scrollable container in playground
|
||||
*/
|
||||
scrollContainer?: string | HTMLElement | Window;
|
||||
/**
|
||||
* The offset between the top of viewport and the input cursor
|
||||
*
|
||||
* @default 46 The height of header in playground
|
||||
*/
|
||||
scrollTopOffset?: number | (() => number);
|
||||
};
|
||||
}
|
||||
|
||||
export type LinkedMenuItem = {
|
||||
key: string;
|
||||
name: string | TemplateResult<1>;
|
||||
icon: TemplateResult<1>;
|
||||
suffix?: string | TemplateResult<1>;
|
||||
// disabled?: boolean;
|
||||
action: LinkedMenuAction;
|
||||
};
|
||||
|
||||
export type LinkedMenuAction = () => Promise<void> | void;
|
||||
|
||||
export type LinkedMenuGroup = {
|
||||
name: string;
|
||||
items: LinkedMenuItem[] | Signal<LinkedMenuItem[]>;
|
||||
styles?: string;
|
||||
// maximum quantity displayed by default
|
||||
maxDisplay?: number;
|
||||
// if the menu is loading
|
||||
loading?: boolean | Signal<boolean>;
|
||||
// copywriting when display quantity exceeds
|
||||
overflowText?: string | Signal<string>;
|
||||
// hide the group
|
||||
hidden?: boolean | Signal<boolean>;
|
||||
};
|
||||
|
||||
export type LinkedDocContext = {
|
||||
std: BlockStdScope;
|
||||
inlineEditor: AffineInlineEditor;
|
||||
startRange: InlineRange;
|
||||
triggerKey: string;
|
||||
config: LinkedWidgetConfig;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_DOC_NAME = 'Untitled';
|
||||
const DISPLAY_NAME_LENGTH = 8;
|
||||
|
||||
export function createLinkedDocMenuGroup(
|
||||
query: string,
|
||||
abort: () => void,
|
||||
editorHost: EditorHost,
|
||||
inlineEditor: AffineInlineEditor
|
||||
) {
|
||||
const doc = editorHost.doc;
|
||||
const { docMetas } = doc.workspace.meta;
|
||||
const filteredDocList = docMetas
|
||||
.filter(({ id }) => id !== doc.id)
|
||||
.filter(({ title }) => isFuzzyMatch(title, query));
|
||||
const MAX_DOCS = 6;
|
||||
|
||||
return {
|
||||
name: 'Link to Doc',
|
||||
items: filteredDocList.map(doc => ({
|
||||
key: doc.id,
|
||||
name: doc.title || DEFAULT_DOC_NAME,
|
||||
icon:
|
||||
editorHost.std.get(DocModeProvider).getPrimaryMode(doc.id) ===
|
||||
'edgeless'
|
||||
? LinkedEdgelessIcon
|
||||
: LinkedDocIcon,
|
||||
action: () => {
|
||||
abort();
|
||||
insertLinkedNode({
|
||||
inlineEditor,
|
||||
docId: doc.id,
|
||||
});
|
||||
editorHost.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('LinkedDocCreated', {
|
||||
control: 'linked doc',
|
||||
module: 'inline @',
|
||||
type: 'doc',
|
||||
other: 'existing doc',
|
||||
});
|
||||
},
|
||||
})),
|
||||
maxDisplay: MAX_DOCS,
|
||||
overflowText: `${filteredDocList.length - MAX_DOCS} more docs`,
|
||||
};
|
||||
}
|
||||
|
||||
export function createNewDocMenuGroup(
|
||||
query: string,
|
||||
abort: () => void,
|
||||
editorHost: EditorHost,
|
||||
inlineEditor: AffineInlineEditor
|
||||
): LinkedMenuGroup {
|
||||
const doc = editorHost.doc;
|
||||
const docName = query || DEFAULT_DOC_NAME;
|
||||
const displayDocName =
|
||||
docName.slice(0, DISPLAY_NAME_LENGTH) +
|
||||
(docName.length > DISPLAY_NAME_LENGTH ? '..' : '');
|
||||
|
||||
return {
|
||||
name: 'New Doc',
|
||||
items: [
|
||||
{
|
||||
key: 'create',
|
||||
name: `Create "${displayDocName}" doc`,
|
||||
icon: NewDocIcon,
|
||||
action: () => {
|
||||
abort();
|
||||
const docName = query;
|
||||
const newDoc = createDefaultDoc(doc.workspace, {
|
||||
title: docName,
|
||||
});
|
||||
insertLinkedNode({
|
||||
inlineEditor,
|
||||
docId: newDoc.id,
|
||||
});
|
||||
const telemetryService =
|
||||
editorHost.std.getOptional(TelemetryProvider);
|
||||
telemetryService?.track('LinkedDocCreated', {
|
||||
control: 'new doc',
|
||||
module: 'inline @',
|
||||
type: 'doc',
|
||||
other: 'new doc',
|
||||
});
|
||||
telemetryService?.track('DocCreated', {
|
||||
control: 'new doc',
|
||||
module: 'inline @',
|
||||
type: 'doc',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
name: 'Import',
|
||||
icon: ImportIcon,
|
||||
action: () => {
|
||||
abort();
|
||||
const onSuccess = (
|
||||
docIds: string[],
|
||||
options: {
|
||||
importedCount: number;
|
||||
}
|
||||
) => {
|
||||
toast(
|
||||
editorHost,
|
||||
`Successfully imported ${options.importedCount} Doc${options.importedCount > 1 ? 's' : ''}.`
|
||||
);
|
||||
for (const docId of docIds) {
|
||||
insertLinkedNode({
|
||||
inlineEditor,
|
||||
docId,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onFail = (message: string) => {
|
||||
toast(editorHost, message);
|
||||
};
|
||||
showImportModal({
|
||||
collection: doc.workspace,
|
||||
schema: doc.schema,
|
||||
onSuccess,
|
||||
onFail,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function getMenus(
|
||||
query: string,
|
||||
abort: () => void,
|
||||
editorHost: EditorHost,
|
||||
inlineEditor: AffineInlineEditor
|
||||
): Promise<LinkedMenuGroup[]> {
|
||||
return Promise.resolve([
|
||||
createLinkedDocMenuGroup(query, abort, editorHost, inlineEditor),
|
||||
createNewDocMenuGroup(query, abort, editorHost, inlineEditor),
|
||||
]);
|
||||
}
|
||||
|
||||
export const LinkedWidgetUtils = {
|
||||
createLinkedDocMenuGroup,
|
||||
createNewDocMenuGroup,
|
||||
insertLinkedNode,
|
||||
};
|
||||
|
||||
export const AFFINE_LINKED_DOC_WIDGET = 'affine-linked-doc-widget';
|
||||
@@ -1,16 +0,0 @@
|
||||
import { AFFINE_LINKED_DOC_WIDGET } from './config.js';
|
||||
import { ImportDoc } from './import-doc/import-doc.js';
|
||||
import { AffineLinkedDocWidget } from './index.js';
|
||||
import { LinkedDocPopover } from './linked-doc-popover.js';
|
||||
import { AffineMobileLinkedDocMenu } from './mobile-linked-doc-menu.js';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-linked-doc-popover', LinkedDocPopover);
|
||||
customElements.define(AFFINE_LINKED_DOC_WIDGET, AffineLinkedDocWidget);
|
||||
customElements.define('import-doc', ImportDoc);
|
||||
|
||||
customElements.define(
|
||||
'affine-mobile-linked-doc-menu',
|
||||
AffineMobileLinkedDocMenu
|
||||
);
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import {
|
||||
CloseIcon,
|
||||
ExportToHTMLIcon,
|
||||
ExportToMarkdownIcon,
|
||||
HelpIcon,
|
||||
NewIcon,
|
||||
NotionIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { openFileOrFiles } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import type { Schema, Workspace } from '@blocksuite/store';
|
||||
import { html, LitElement, type PropertyValues } from 'lit';
|
||||
import { query, state } from 'lit/decorators.js';
|
||||
|
||||
import { HtmlTransformer } from '../../../transformers/html.js';
|
||||
import { MarkdownTransformer } from '../../../transformers/markdown.js';
|
||||
import { NotionHtmlTransformer } from '../../../transformers/notion-html.js';
|
||||
import { styles } from './styles.js';
|
||||
|
||||
export type OnSuccessHandler = (
|
||||
pageIds: string[],
|
||||
options: { isWorkspaceFile: boolean; importedCount: number }
|
||||
) => void;
|
||||
|
||||
export type OnFailHandler = (message: string) => void;
|
||||
|
||||
const SHOW_LOADING_SIZE = 1024 * 200;
|
||||
|
||||
export class ImportDoc extends WithDisposable(LitElement) {
|
||||
static override styles = styles;
|
||||
|
||||
constructor(
|
||||
private readonly collection: Workspace,
|
||||
private readonly schema: Schema,
|
||||
private readonly onSuccess?: OnSuccessHandler,
|
||||
private readonly onFail?: OnFailHandler,
|
||||
private readonly abortController = new AbortController()
|
||||
) {
|
||||
super();
|
||||
|
||||
this._loading = false;
|
||||
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this._startX = 0;
|
||||
this._startY = 0;
|
||||
|
||||
this._onMouseMove = this._onMouseMove.bind(this);
|
||||
}
|
||||
|
||||
private async _importHtml() {
|
||||
const files = await openFileOrFiles({ acceptType: 'Html', multiple: true });
|
||||
if (!files) return;
|
||||
const pageIds: string[] = [];
|
||||
for (const file of files) {
|
||||
const text = await file.text();
|
||||
const needLoading = file.size > SHOW_LOADING_SIZE;
|
||||
const fileName = file.name.split('.').slice(0, -1).join('.');
|
||||
if (needLoading) {
|
||||
this.hidden = false;
|
||||
this._loading = true;
|
||||
} else {
|
||||
this.abortController.abort();
|
||||
}
|
||||
const pageId = await HtmlTransformer.importHTMLToDoc({
|
||||
collection: this.collection,
|
||||
schema: this.schema,
|
||||
html: text,
|
||||
fileName,
|
||||
});
|
||||
needLoading && this.abortController.abort();
|
||||
if (pageId) {
|
||||
pageIds.push(pageId);
|
||||
}
|
||||
}
|
||||
this._onImportSuccess(pageIds);
|
||||
}
|
||||
|
||||
private async _importMarkDown() {
|
||||
const files = await openFileOrFiles({
|
||||
acceptType: 'Markdown',
|
||||
multiple: true,
|
||||
});
|
||||
if (!files) return;
|
||||
const pageIds: string[] = [];
|
||||
for (const file of files) {
|
||||
const text = await file.text();
|
||||
const fileName = file.name.split('.').slice(0, -1).join('.');
|
||||
const needLoading = file.size > SHOW_LOADING_SIZE;
|
||||
if (needLoading) {
|
||||
this.hidden = false;
|
||||
this._loading = true;
|
||||
} else {
|
||||
this.abortController.abort();
|
||||
}
|
||||
const pageId = await MarkdownTransformer.importMarkdownToDoc({
|
||||
collection: this.collection,
|
||||
schema: this.schema,
|
||||
markdown: text,
|
||||
fileName,
|
||||
});
|
||||
needLoading && this.abortController.abort();
|
||||
if (pageId) {
|
||||
pageIds.push(pageId);
|
||||
}
|
||||
}
|
||||
this._onImportSuccess(pageIds);
|
||||
}
|
||||
|
||||
private async _importNotion() {
|
||||
const file = await openFileOrFiles({ acceptType: 'Zip' });
|
||||
if (!file) return;
|
||||
const needLoading = file.size > SHOW_LOADING_SIZE;
|
||||
if (needLoading) {
|
||||
this.hidden = false;
|
||||
this._loading = true;
|
||||
} else {
|
||||
this.abortController.abort();
|
||||
}
|
||||
const { entryId, pageIds, isWorkspaceFile, hasMarkdown } =
|
||||
await NotionHtmlTransformer.importNotionZip({
|
||||
collection: this.collection,
|
||||
schema: this.schema,
|
||||
imported: file,
|
||||
});
|
||||
needLoading && this.abortController.abort();
|
||||
if (hasMarkdown) {
|
||||
this._onFail(
|
||||
'Importing markdown files from Notion is deprecated. Please export your Notion pages as HTML.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._onImportSuccess([entryId], {
|
||||
isWorkspaceFile,
|
||||
importedCount: pageIds.length,
|
||||
});
|
||||
}
|
||||
|
||||
private _onCloseClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private _onFail(message: string) {
|
||||
this.onFail?.(message);
|
||||
}
|
||||
|
||||
private _onImportSuccess(
|
||||
pageIds: string[],
|
||||
options: { isWorkspaceFile?: boolean; importedCount?: number } = {}
|
||||
) {
|
||||
const {
|
||||
isWorkspaceFile = false,
|
||||
importedCount: pagesImportedCount = pageIds.length,
|
||||
} = options;
|
||||
this.onSuccess?.(pageIds, {
|
||||
isWorkspaceFile,
|
||||
importedCount: pagesImportedCount,
|
||||
});
|
||||
}
|
||||
|
||||
private _onMouseDown(event: MouseEvent) {
|
||||
this._startX = event.clientX - this.x;
|
||||
this._startY = event.clientY - this.y;
|
||||
window.addEventListener('mousemove', this._onMouseMove);
|
||||
}
|
||||
|
||||
private _onMouseMove(event: MouseEvent) {
|
||||
this.x = event.clientX - this._startX;
|
||||
this.y = event.clientY - this._startY;
|
||||
}
|
||||
|
||||
private _onMouseUp() {
|
||||
window.removeEventListener('mousemove', this._onMouseMove);
|
||||
}
|
||||
|
||||
private _openLearnImportLink(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
window.open(
|
||||
'https://affine.pro/blog/import-your-data-from-notion-into-affine',
|
||||
'_blank'
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this._loading) {
|
||||
return html`
|
||||
<div class="overlay-mask"></div>
|
||||
<div class="container">
|
||||
<header
|
||||
class="loading-header"
|
||||
@mousedown="${this._onMouseDown}"
|
||||
@mouseup="${this._onMouseUp}"
|
||||
>
|
||||
<div>Import</div>
|
||||
<loader-element .width=${'50px'}></loader-element>
|
||||
</header>
|
||||
<div>
|
||||
Importing the file may take some time. It depends on document size
|
||||
and complexity.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
class="overlay-mask"
|
||||
@click="${() => this.abortController.abort()}"
|
||||
></div>
|
||||
<div class="container">
|
||||
<header @mousedown="${this._onMouseDown}" @mouseup="${this._onMouseUp}">
|
||||
<icon-button height="28px" @click="${this._onCloseClick}">
|
||||
${CloseIcon}
|
||||
</icon-button>
|
||||
<div>Import</div>
|
||||
</header>
|
||||
<div>
|
||||
AFFiNE will gradually support more file formats for import.
|
||||
<a
|
||||
href="https://community.affine.pro/c/feature-requests/import-export"
|
||||
target="_blank"
|
||||
>Provide feedback.</a
|
||||
>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<icon-button
|
||||
class="button-item"
|
||||
text="Markdown"
|
||||
@click="${this._importMarkDown}"
|
||||
>
|
||||
${ExportToMarkdownIcon}
|
||||
</icon-button>
|
||||
<icon-button
|
||||
class="button-item"
|
||||
text="HTML"
|
||||
@click="${this._importHtml}"
|
||||
>
|
||||
${ExportToHTMLIcon}
|
||||
</icon-button>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<icon-button
|
||||
class="button-item"
|
||||
text="Notion"
|
||||
@click="${this._importNotion}"
|
||||
>
|
||||
${NotionIcon}
|
||||
<div
|
||||
slot="suffix"
|
||||
class="button-suffix"
|
||||
@click="${this._openLearnImportLink}"
|
||||
>
|
||||
${HelpIcon}
|
||||
<affine-tooltip>
|
||||
Learn how to Import your Notion pages into AFFiNE.
|
||||
</affine-tooltip>
|
||||
</div>
|
||||
</icon-button>
|
||||
<icon-button class="button-item" text="Coming soon..." disabled>
|
||||
${NewIcon}
|
||||
</icon-button>
|
||||
</div>
|
||||
<!-- <div class="footer">
|
||||
<div>Migrate from other versions of AFFiNE?</div>
|
||||
</div> -->
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override updated(changedProps: PropertyValues) {
|
||||
if (changedProps.has('x') || changedProps.has('y')) {
|
||||
this.containerEl.style.transform = `translate(${this.x}px, ${this.y}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor _loading = false;
|
||||
|
||||
@state()
|
||||
accessor _startX = 0;
|
||||
|
||||
@state()
|
||||
accessor _startY = 0;
|
||||
|
||||
@query('.container')
|
||||
accessor containerEl!: HTMLElement;
|
||||
|
||||
@state()
|
||||
accessor x = 0;
|
||||
|
||||
@state()
|
||||
accessor y = 0;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Schema, Workspace } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
ImportDoc,
|
||||
type OnFailHandler,
|
||||
type OnSuccessHandler,
|
||||
} from './import-doc.js';
|
||||
|
||||
export function showImportModal({
|
||||
schema,
|
||||
collection,
|
||||
onSuccess,
|
||||
onFail,
|
||||
container = document.body,
|
||||
abortController = new AbortController(),
|
||||
}: {
|
||||
schema: Schema;
|
||||
collection: Workspace;
|
||||
onSuccess?: OnSuccessHandler;
|
||||
onFail?: OnFailHandler;
|
||||
multiple?: boolean;
|
||||
container?: HTMLElement;
|
||||
abortController?: AbortController;
|
||||
}) {
|
||||
const importDoc = new ImportDoc(
|
||||
collection,
|
||||
schema,
|
||||
onSuccess,
|
||||
onFail,
|
||||
abortController
|
||||
);
|
||||
container.append(importDoc);
|
||||
abortController.signal.addEventListener('abort', () => importDoc.remove());
|
||||
return importDoc;
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { BLOCK_ID_ATTR } from '@blocksuite/std';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
export class Loader extends LitElement {
|
||||
static override styles = css`
|
||||
.load-container {
|
||||
margin: 10px auto;
|
||||
width: var(--loader-width);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-container .load {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--affine-text-primary-color);
|
||||
|
||||
border-radius: 100%;
|
||||
display: inline-block;
|
||||
-webkit-animation: bouncedelay 1.4s infinite ease-in-out;
|
||||
animation: bouncedelay 1.4s infinite ease-in-out;
|
||||
/* Prevent first note from flickering when animation starts */
|
||||
-webkit-animation-fill-mode: both;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.load-container .load1 {
|
||||
-webkit-animation-delay: -0.32s;
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
.load-container .load2 {
|
||||
-webkit-animation-delay: -0.16s;
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@-webkit-keyframes bouncedelay {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
-webkit-transform: scale(0.625);
|
||||
}
|
||||
40% {
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bouncedelay {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
-webkit-transform: scale(0.625);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hostModel) {
|
||||
this.setAttribute(BLOCK_ID_ATTR, this.hostModel.id);
|
||||
this.dataset.serviceLoading = 'true';
|
||||
}
|
||||
|
||||
const width = this.width;
|
||||
this.style.setProperty(
|
||||
'--loader-width',
|
||||
typeof width === 'string' ? width : `${width}px`
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="load-container">
|
||||
<div class="load load1"></div>
|
||||
<div class="load load2"></div>
|
||||
<div class="load"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hostModel: BlockModel | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor radius: string | number = '8px';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor width: string | number = '150px';
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'loader-element': Loader;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, unsafeCSS } from 'lit';
|
||||
|
||||
export const styles = css`
|
||||
.container {
|
||||
position: absolute;
|
||||
width: 480px;
|
||||
left: calc(50% - 480px / 2);
|
||||
top: calc(50% - 270px / 2);
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
font-size: var(--affine-font-base);
|
||||
line-height: var(--affine-line-height);
|
||||
padding: 12px 40px 36px;
|
||||
gap: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--affine-background-primary-color);
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
border-radius: 16px;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
|
||||
.container[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
header {
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
font-size: var(--affine-font-h-6);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a {
|
||||
white-space: nowrap;
|
||||
word-break: break-word;
|
||||
color: var(--affine-link-color);
|
||||
fill: var(--affine-link-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
header icon-button {
|
||||
margin-left: auto;
|
||||
position: relative;
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.button-container icon-button {
|
||||
padding: 8px 12px;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
width: 190px;
|
||||
height: 40px;
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.loading-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button-suffix {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.overlay-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
`;
|
||||
@@ -1,323 +0,0 @@
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
getRangeRects,
|
||||
type SelectionRect,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { BLOCK_ID_ATTR, WidgetComponent } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import {
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineEditor,
|
||||
type InlineRootElement,
|
||||
} from '@blocksuite/std/inline';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { PageRootBlockComponent } from '../../page/page-root-block.js';
|
||||
import { RootBlockConfigExtension } from '../../root-config.js';
|
||||
import {
|
||||
type AFFINE_LINKED_DOC_WIDGET,
|
||||
getMenus,
|
||||
type LinkedDocContext,
|
||||
type LinkedWidgetConfig,
|
||||
} from './config.js';
|
||||
import { linkedDocWidgetStyles } from './styles.js';
|
||||
export { type LinkedWidgetConfig } from './config.js';
|
||||
|
||||
export class AffineLinkedDocWidget extends WidgetComponent<
|
||||
RootBlockModel,
|
||||
PageRootBlockComponent
|
||||
> {
|
||||
static override styles = linkedDocWidgetStyles;
|
||||
|
||||
private _context: LinkedDocContext | null = null;
|
||||
|
||||
private readonly _inputRects$ = signal<SelectionRect[]>([]);
|
||||
|
||||
private readonly _mode$ = signal<'desktop' | 'mobile' | 'none'>('none');
|
||||
|
||||
private _updateInputRects() {
|
||||
if (!this._context) return;
|
||||
const { inlineEditor, startRange, triggerKey } = this._context;
|
||||
|
||||
const currentInlineRange = inlineEditor.getInlineRange();
|
||||
if (!currentInlineRange) return;
|
||||
|
||||
const startIndex = startRange.index - triggerKey.length;
|
||||
const range = inlineEditor.toDomRange({
|
||||
index: startIndex,
|
||||
length: currentInlineRange.index - startIndex,
|
||||
});
|
||||
if (!range) return;
|
||||
|
||||
this._inputRects$.value = getRangeRects(
|
||||
range,
|
||||
getViewportElement(this.host)
|
||||
);
|
||||
}
|
||||
|
||||
private get _isCursorAtEnd() {
|
||||
if (!this._context) return false;
|
||||
const { inlineEditor } = this._context;
|
||||
const currentInlineRange = inlineEditor.getInlineRange();
|
||||
if (!currentInlineRange) return false;
|
||||
return currentInlineRange.index === inlineEditor.yTextLength;
|
||||
}
|
||||
|
||||
private readonly _renderLinkedDocMenu = () => {
|
||||
if (!this.block?.rootComponent) return nothing;
|
||||
|
||||
return html`<affine-mobile-linked-doc-menu
|
||||
.context=${this._context}
|
||||
.rootComponent=${this.block.rootComponent}
|
||||
></affine-mobile-linked-doc-menu>`;
|
||||
};
|
||||
|
||||
private readonly _renderLinkedDocPopover = () => {
|
||||
return html`<affine-linked-doc-popover
|
||||
.context=${this._context}
|
||||
></affine-linked-doc-popover>`;
|
||||
};
|
||||
|
||||
private _renderInputMask() {
|
||||
return html`${repeat(
|
||||
this._inputRects$.value,
|
||||
({ top, left, width, height }, index) => {
|
||||
const last =
|
||||
index === this._inputRects$.value.length - 1 && this._isCursorAtEnd;
|
||||
|
||||
const padding = 2;
|
||||
return html`<div
|
||||
class="input-mask"
|
||||
style=${styleMap({
|
||||
top: `${top - padding}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width + (last ? 10 : 0)}px`,
|
||||
height: `${height + 2 * padding}px`,
|
||||
})}
|
||||
></div>`;
|
||||
}
|
||||
)}`;
|
||||
}
|
||||
|
||||
private _watchInput() {
|
||||
this.handleEvent('beforeInput', ctx => {
|
||||
if (this._mode$.peek() !== 'none') return;
|
||||
|
||||
const event = ctx.get('defaultState').event;
|
||||
if (!(event instanceof InputEvent)) return;
|
||||
|
||||
if (event.data === null) return;
|
||||
|
||||
const host = this.std.host;
|
||||
|
||||
const range = host.range.value;
|
||||
if (!range || !range.collapsed) return;
|
||||
|
||||
const containerElement =
|
||||
range.commonAncestorContainer instanceof Element
|
||||
? range.commonAncestorContainer
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
if (!containerElement) return;
|
||||
|
||||
if (containerElement.closest(this.config.ignoreSelector)) return;
|
||||
|
||||
const block = containerElement.closest<BlockComponent>(
|
||||
`[${BLOCK_ID_ATTR}]`
|
||||
);
|
||||
if (!block || this.config.ignoreBlockTypes.includes(block.flavour))
|
||||
return;
|
||||
|
||||
const inlineRoot = containerElement.closest<InlineRootElement>(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
);
|
||||
if (!inlineRoot) return;
|
||||
|
||||
const inlineEditor = inlineRoot.inlineEditor;
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
const triggerKeys = this.config.triggerKeys;
|
||||
const primaryTriggerKey = triggerKeys[0];
|
||||
const convertTriggerKey = this.config.convertTriggerKey;
|
||||
if (primaryTriggerKey.length > inlineRange.index) return;
|
||||
const matchedText = inlineEditor.yTextString.slice(
|
||||
inlineRange.index - primaryTriggerKey.length,
|
||||
inlineRange.index
|
||||
);
|
||||
|
||||
let converted = false;
|
||||
if (matchedText !== primaryTriggerKey && convertTriggerKey) {
|
||||
for (const key of triggerKeys.slice(1)) {
|
||||
if (key.length > inlineRange.index) continue;
|
||||
const matchedText = inlineEditor.yTextString.slice(
|
||||
inlineRange.index - key.length,
|
||||
inlineRange.index
|
||||
);
|
||||
if (matchedText === key) {
|
||||
const startIdxBeforeMatchKey = inlineRange.index - key.length;
|
||||
inlineEditor.deleteText({
|
||||
index: startIdxBeforeMatchKey,
|
||||
length: key.length,
|
||||
});
|
||||
inlineEditor.insertText(
|
||||
{ index: startIdxBeforeMatchKey, length: 0 },
|
||||
primaryTriggerKey
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIdxBeforeMatchKey + primaryTriggerKey.length,
|
||||
length: 0,
|
||||
});
|
||||
converted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedText !== primaryTriggerKey && !converted) return;
|
||||
|
||||
inlineEditor
|
||||
.waitForUpdate()
|
||||
.then(() => {
|
||||
this.show({
|
||||
inlineEditor,
|
||||
primaryTriggerKey,
|
||||
mode: IS_MOBILE ? 'mobile' : 'desktop',
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
});
|
||||
}
|
||||
|
||||
private _watchViewportChange() {
|
||||
const gfx = this.std.get(GfxControllerIdentifier);
|
||||
this.disposables.add(
|
||||
gfx.viewport.viewportUpdated.subscribe(() => {
|
||||
this._updateInputRects();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
get config(): LinkedWidgetConfig {
|
||||
return {
|
||||
triggerKeys: ['@', '[[', '【【'],
|
||||
ignoreBlockTypes: ['affine:code'],
|
||||
ignoreSelector:
|
||||
'edgeless-text-editor, edgeless-shape-text-editor, edgeless-group-title-editor, edgeless-frame-title-editor, edgeless-connector-label-editor',
|
||||
convertTriggerKey: true,
|
||||
getMenus,
|
||||
mobile: {
|
||||
scrollContainer: getViewportElement(this.std.host) ?? window,
|
||||
scrollTopOffset: 46,
|
||||
},
|
||||
...this.std.getOptional(RootBlockConfigExtension.identifier)
|
||||
?.linkedWidget,
|
||||
};
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this._watchInput();
|
||||
this._watchViewportChange();
|
||||
}
|
||||
|
||||
show(props?: {
|
||||
inlineEditor?: InlineEditor;
|
||||
primaryTriggerKey?: string;
|
||||
mode?: 'desktop' | 'mobile';
|
||||
addTriggerKey?: boolean;
|
||||
}) {
|
||||
const host = this.host;
|
||||
const {
|
||||
primaryTriggerKey = '@',
|
||||
mode = 'desktop',
|
||||
addTriggerKey = false,
|
||||
} = props ?? {};
|
||||
let inlineEditor: InlineEditor;
|
||||
if (!props?.inlineEditor) {
|
||||
const range = host.range.value;
|
||||
if (!range || !range.collapsed) return;
|
||||
const containerElement =
|
||||
range.commonAncestorContainer instanceof Element
|
||||
? range.commonAncestorContainer
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
if (!containerElement) return;
|
||||
const inlineRoot = containerElement.closest<InlineRootElement>(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
);
|
||||
if (!inlineRoot) return;
|
||||
inlineEditor = inlineRoot.inlineEditor;
|
||||
} else {
|
||||
inlineEditor = props.inlineEditor;
|
||||
}
|
||||
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
if (addTriggerKey) {
|
||||
inlineEditor.insertText(
|
||||
{ index: inlineRange.index, length: 0 },
|
||||
primaryTriggerKey
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + primaryTriggerKey.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const disposable = inlineEditor.slots.renderComplete.subscribe(() => {
|
||||
this._updateInputRects();
|
||||
});
|
||||
this._context = {
|
||||
std: this.std,
|
||||
inlineEditor,
|
||||
startRange: inlineRange,
|
||||
triggerKey: primaryTriggerKey,
|
||||
config: this.config,
|
||||
close: () => {
|
||||
disposable.unsubscribe();
|
||||
this._inputRects$.value = [];
|
||||
this._mode$.value = 'none';
|
||||
this._context = null;
|
||||
},
|
||||
};
|
||||
|
||||
this._updateInputRects();
|
||||
|
||||
const enableMobile = this.doc
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_mobile_linked_doc_menu');
|
||||
this._mode$.value = enableMobile ? mode : 'desktop';
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this._mode$.value === 'none') return nothing;
|
||||
|
||||
return html`${this._renderInputMask()}
|
||||
<blocksuite-portal
|
||||
.shadowDom=${false}
|
||||
.template=${choose(
|
||||
this._mode$.value,
|
||||
[
|
||||
['desktop', this._renderLinkedDocPopover],
|
||||
['mobile', this._renderLinkedDocMenu],
|
||||
],
|
||||
() => html`${nothing}`
|
||||
)}
|
||||
></blocksuite-portal>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_LINKED_DOC_WIDGET]: AffineLinkedDocWidget;
|
||||
}
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
import { LoadingIcon } from '@blocksuite/affine-block-image';
|
||||
import type { IconButton } from '@blocksuite/affine-components/icon-button';
|
||||
import {
|
||||
cleanSpecifiedTail,
|
||||
getTextContentFromInlineRange,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
|
||||
import {
|
||||
createKeydownObserver,
|
||||
getCurrentNativeRange,
|
||||
getPopperPosition,
|
||||
getViewportElement,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
|
||||
import { PropTypes, requiredProperties } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property, query, queryAll, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
|
||||
import type { LinkedDocContext, LinkedMenuGroup } from './config.js';
|
||||
import { linkedDocPopoverStyles } from './styles.js';
|
||||
import { resolveSignal } from './utils.js';
|
||||
|
||||
@requiredProperties({
|
||||
context: PropTypes.object,
|
||||
})
|
||||
export class LinkedDocPopover extends SignalWatcher(
|
||||
WithDisposable(LitElement)
|
||||
) {
|
||||
static override styles = linkedDocPopoverStyles;
|
||||
|
||||
private readonly _abort = () => {
|
||||
// remove popover dom
|
||||
this.context.close();
|
||||
// clear input query
|
||||
cleanSpecifiedTail(
|
||||
this.context.std,
|
||||
this.context.inlineEditor,
|
||||
this.context.triggerKey + (this._query || '')
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _expanded = new Map<string, boolean>();
|
||||
|
||||
private _menusItemsEffectCleanup: () => void = () => {};
|
||||
|
||||
private readonly _updateLinkedDocGroup = async () => {
|
||||
const query = this._query;
|
||||
if (this._updateLinkedDocGroupAbortController) {
|
||||
this._updateLinkedDocGroupAbortController.abort();
|
||||
}
|
||||
this._updateLinkedDocGroupAbortController = new AbortController();
|
||||
|
||||
if (query === null || query.startsWith(' ')) {
|
||||
this.context.close();
|
||||
return;
|
||||
}
|
||||
this._linkedDocGroup = await this.context.config.getMenus(
|
||||
query,
|
||||
this._abort,
|
||||
this.context.std.host,
|
||||
this.context.inlineEditor,
|
||||
this._updateLinkedDocGroupAbortController.signal
|
||||
);
|
||||
|
||||
this._menusItemsEffectCleanup();
|
||||
|
||||
// need to rebind the effect because this._linkedDocGroup has changed.
|
||||
this._menusItemsEffectCleanup = effect(() => {
|
||||
this._updateAutoFocusedItem();
|
||||
|
||||
// wait for the next tick to ensure the items are rendered to DOM
|
||||
setTimeout(() => {
|
||||
this.scrollToFocusedItem();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _updateAutoFocusedItem = () => {
|
||||
// Get the auto-focused item key from the config
|
||||
const autoFocusedItemKey = this.context.config.autoFocusedItemKey?.(
|
||||
this._linkedDocGroup,
|
||||
this._query || '',
|
||||
this._activatedItemKey,
|
||||
this.context.std.host,
|
||||
this.context.inlineEditor
|
||||
);
|
||||
|
||||
if (autoFocusedItemKey) {
|
||||
this._activatedItemKey = autoFocusedItemKey;
|
||||
return;
|
||||
}
|
||||
|
||||
// If no auto-focused item key is returned from the config and no item is currently focused,
|
||||
// focus the first item in the flattened action list
|
||||
if (!this._activatedItemKey && this._flattenActionList.length > 0) {
|
||||
this._activatedItemKey = this._flattenActionList[0].key;
|
||||
}
|
||||
};
|
||||
|
||||
private _updateLinkedDocGroupAbortController: AbortController | null = null;
|
||||
|
||||
private get _actionGroup() {
|
||||
return this._linkedDocGroup.map(group => {
|
||||
return {
|
||||
...group,
|
||||
items: this._getActionItems(group),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private get _flattenActionList() {
|
||||
return this._actionGroup
|
||||
.map(group =>
|
||||
group.items.map(item => ({ ...item, groupName: group.name }))
|
||||
)
|
||||
.flat();
|
||||
}
|
||||
|
||||
private get _query() {
|
||||
return getTextContentFromInlineRange(
|
||||
this.context.inlineEditor,
|
||||
this.context.startRange
|
||||
);
|
||||
}
|
||||
|
||||
private _getActionItems(group: LinkedMenuGroup) {
|
||||
const isExpanded = !!this._expanded.get(group.name);
|
||||
let items = resolveSignal(group.items);
|
||||
|
||||
const isOverflow = !!group.maxDisplay && items.length > group.maxDisplay;
|
||||
|
||||
items = isExpanded ? items : items.slice(0, group.maxDisplay);
|
||||
|
||||
if (isOverflow && !isExpanded && group.maxDisplay) {
|
||||
items = items.concat({
|
||||
key: `${group.name} More`,
|
||||
name: resolveSignal(group.overflowText) || 'more',
|
||||
icon: MoreHorizontalIcon({ width: '24px', height: '24px' }),
|
||||
action: () => {
|
||||
this._expanded.set(group.name, true);
|
||||
this.requestUpdate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private _isTextOverflowing(element: HTMLElement) {
|
||||
return element.scrollWidth > element.clientWidth;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// init
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
this._disposables.addFromEvent(this, 'mousedown', e => {
|
||||
// Prevent input from losing focus
|
||||
e.preventDefault();
|
||||
});
|
||||
this._disposables.addFromEvent(window, 'mousedown', e => {
|
||||
if (e.target === this) return;
|
||||
// We don't clear the query when clicking outside the popover
|
||||
this.context.close();
|
||||
});
|
||||
|
||||
const keydownObserverAbortController = new AbortController();
|
||||
this._disposables.add(() => keydownObserverAbortController.abort());
|
||||
|
||||
const { eventSource } = this.context.inlineEditor;
|
||||
if (!eventSource) return;
|
||||
|
||||
createKeydownObserver({
|
||||
target: eventSource,
|
||||
signal: keydownObserverAbortController.signal,
|
||||
interceptor: (event, next) => {
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
this.context.close();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
},
|
||||
onInput: isComposition => {
|
||||
if (isComposition) {
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
} else {
|
||||
const subscription =
|
||||
this.context.inlineEditor.slots.renderComplete.subscribe(() => {
|
||||
subscription.unsubscribe();
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
});
|
||||
}
|
||||
},
|
||||
onPaste: () => {
|
||||
setTimeout(() => {
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
}, 50);
|
||||
},
|
||||
onDelete: () => {
|
||||
const curRange = this.context.inlineEditor.getInlineRange();
|
||||
if (!this.context.startRange || !curRange) {
|
||||
return;
|
||||
}
|
||||
if (curRange.index < this.context.startRange.index) {
|
||||
this.context.close();
|
||||
}
|
||||
const subscription =
|
||||
this.context.inlineEditor.slots.renderComplete.subscribe(() => {
|
||||
subscription.unsubscribe();
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
});
|
||||
},
|
||||
onMove: step => {
|
||||
const itemLen = this._flattenActionList.length;
|
||||
const nextIndex = (itemLen + this._activatedItemIndex + step) % itemLen;
|
||||
const item = this._flattenActionList[nextIndex];
|
||||
if (item) {
|
||||
this._activatedItemKey = item.key;
|
||||
}
|
||||
this.scrollToFocusedItem();
|
||||
},
|
||||
onConfirm: () => {
|
||||
this._flattenActionList[this._activatedItemIndex]
|
||||
.action()
|
||||
?.catch(console.error);
|
||||
},
|
||||
onAbort: () => {
|
||||
this.context.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._menusItemsEffectCleanup();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const MAX_HEIGHT = 390;
|
||||
const style = this._position
|
||||
? styleMap({
|
||||
transform: `translate(${this._position.x}, ${this._position.y})`,
|
||||
maxHeight: `${Math.min(this._position.height, MAX_HEIGHT)}px`,
|
||||
})
|
||||
: styleMap({
|
||||
visibility: 'hidden',
|
||||
});
|
||||
|
||||
const actionGroups = this._actionGroup.map(group => {
|
||||
// Check if the group is loading or hidden
|
||||
const isLoading = resolveSignal(group.loading);
|
||||
const isHidden = resolveSignal(group.hidden);
|
||||
return {
|
||||
...group,
|
||||
isLoading,
|
||||
isHidden,
|
||||
};
|
||||
});
|
||||
|
||||
return html`<div class="linked-doc-popover" style="${style}">
|
||||
${actionGroups
|
||||
.filter(
|
||||
group =>
|
||||
(group.items.length > 0 || group.isLoading) && !group.isHidden
|
||||
)
|
||||
.map((group, idx) => {
|
||||
return html`
|
||||
<div class="divider" ?hidden=${idx === 0}></div>
|
||||
<div class="group-title">
|
||||
${group.name}
|
||||
${group.isLoading
|
||||
? html`<span class="loading-icon">${LoadingIcon}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="group" style=${group.styles ?? ''}>
|
||||
${group.items.map(({ key, name, icon, action }) => {
|
||||
const tooltip = this._showTooltip
|
||||
? html`<affine-tooltip
|
||||
tip-position=${'right'}
|
||||
.tooltipStyle=${css`
|
||||
* {
|
||||
color: ${unsafeCSSVar('white')} !important;
|
||||
}
|
||||
`}
|
||||
>${name}</affine-tooltip
|
||||
>`
|
||||
: nothing;
|
||||
return html`<icon-button
|
||||
width="260px"
|
||||
height="30px"
|
||||
data-id=${key}
|
||||
.text=${name}
|
||||
hover=${this._activatedItemKey === key}
|
||||
@pointerdown=${(e: PointerEvent) => {
|
||||
// Prevent event listeners being registered on the root document
|
||||
// eg., radix-ui dialogs usePointerDownOutside hooks
|
||||
e.stopPropagation();
|
||||
}}
|
||||
@click=${() => {
|
||||
action()?.catch(console.error);
|
||||
}}
|
||||
@mousemove=${() => {
|
||||
// Use `mousemove` instead of `mouseover` to avoid navigate conflict with keyboard
|
||||
this._activatedItemKey = key;
|
||||
// show tooltip whether text length overflows
|
||||
for (const button of this.iconButtons.values()) {
|
||||
if (button.dataset.id == key && button.textElement) {
|
||||
const isOverflowing = this._isTextOverflowing(
|
||||
button.textElement
|
||||
);
|
||||
this._showTooltip = isOverflowing;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
${icon} ${tooltip}
|
||||
</icon-button>`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override willUpdate() {
|
||||
if (!this.hasUpdated) {
|
||||
const curRange = getCurrentNativeRange();
|
||||
if (!curRange) return;
|
||||
|
||||
const updatePosition = throttle(() => {
|
||||
this._position = getPopperPosition(this, curRange);
|
||||
}, 10);
|
||||
|
||||
this.disposables.addFromEvent(window, 'resize', updatePosition);
|
||||
const scrollContainer = getViewportElement(this.context.std.host);
|
||||
if (scrollContainer) {
|
||||
// Note: in edgeless mode, the scroll container is not exist!
|
||||
this.disposables.addFromEvent(
|
||||
scrollContainer,
|
||||
'scroll',
|
||||
updatePosition,
|
||||
{
|
||||
passive: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const gfx = this.context.std.get(GfxControllerIdentifier);
|
||||
this.disposables.add(
|
||||
gfx.viewport.viewportUpdated.subscribe(updatePosition)
|
||||
);
|
||||
|
||||
updatePosition();
|
||||
}
|
||||
}
|
||||
|
||||
private scrollToFocusedItem() {
|
||||
const shadowRoot = this.shadowRoot;
|
||||
if (!shadowRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's no active item key, don't try to scroll
|
||||
if (!this._activatedItemKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ele = shadowRoot.querySelector(
|
||||
`icon-button[data-id="${this._activatedItemKey}"]`
|
||||
);
|
||||
|
||||
// If the element doesn't exist, don't log a warning
|
||||
if (!ele) {
|
||||
return;
|
||||
}
|
||||
|
||||
ele.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
|
||||
get _activatedItemIndex() {
|
||||
const index = this._flattenActionList.findIndex(
|
||||
item => item.key === this._activatedItemKey
|
||||
);
|
||||
return index === -1 ? 0 : index;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _activatedItemKey: string | null = null;
|
||||
|
||||
@state()
|
||||
private accessor _linkedDocGroup: LinkedMenuGroup[] = [];
|
||||
|
||||
@state()
|
||||
private accessor _position: {
|
||||
height: number;
|
||||
x: string;
|
||||
y: string;
|
||||
} | null = null;
|
||||
|
||||
@state()
|
||||
private accessor _showTooltip = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: LinkedDocContext;
|
||||
|
||||
@queryAll('icon-button')
|
||||
accessor iconButtons!: NodeListOf<IconButton>;
|
||||
|
||||
@query('.linked-doc-popover')
|
||||
accessor linkedDocElement: Element | null = null;
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import {
|
||||
cleanSpecifiedTail,
|
||||
getTextContentFromInlineRange,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import { VirtualKeyboardProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
createKeydownObserver,
|
||||
getViewportElement,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
|
||||
import { PropTypes, requiredProperties } from '@blocksuite/std';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { PageRootBlockComponent } from '../../index.js';
|
||||
import type {
|
||||
LinkedDocContext,
|
||||
LinkedMenuGroup,
|
||||
LinkedMenuItem,
|
||||
} from './config.js';
|
||||
import { mobileLinkedDocMenuStyles } from './styles.js';
|
||||
import { resolveSignal } from './utils.js';
|
||||
|
||||
export const AFFINE_MOBILE_LINKED_DOC_MENU = 'affine-mobile-linked-doc-menu';
|
||||
|
||||
@requiredProperties({
|
||||
context: PropTypes.object,
|
||||
rootComponent: PropTypes.instanceOf(PageRootBlockComponent),
|
||||
})
|
||||
export class AffineMobileLinkedDocMenu extends SignalWatcher(
|
||||
WithDisposable(LitElement)
|
||||
) {
|
||||
static override styles = mobileLinkedDocMenuStyles;
|
||||
|
||||
private readonly _expand = new Set<string>();
|
||||
|
||||
private _firstActionItem: LinkedMenuItem | null = null;
|
||||
|
||||
private readonly _linkedDocGroup$ = signal<LinkedMenuGroup[]>([]);
|
||||
|
||||
private readonly _renderGroup = (group: LinkedMenuGroup) => {
|
||||
let items = resolveSignal(group.items);
|
||||
|
||||
const isOverflow = !!group.maxDisplay && items.length > group.maxDisplay;
|
||||
const expanded = this._expand.has(group.name);
|
||||
|
||||
let moreItem = null;
|
||||
if (!expanded && isOverflow) {
|
||||
items = items.slice(0, group.maxDisplay);
|
||||
|
||||
moreItem = html`<div
|
||||
class="mobile-linked-doc-menu-item"
|
||||
@click=${() => {
|
||||
this._expand.add(group.name);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
>
|
||||
${MoreHorizontalIcon()}
|
||||
<div class="text">${group.overflowText || 'more'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
${repeat(items, item => item.key, this._renderItem)} ${moreItem}
|
||||
`;
|
||||
};
|
||||
|
||||
private readonly _renderItem = ({
|
||||
key,
|
||||
name,
|
||||
icon,
|
||||
action,
|
||||
}: LinkedMenuItem) => {
|
||||
return html`<button
|
||||
class="mobile-linked-doc-menu-item"
|
||||
data-id=${key}
|
||||
@pointerup=${() => {
|
||||
action()?.catch(console.error);
|
||||
}}
|
||||
>
|
||||
${icon}
|
||||
<div class="text">${name}</div>
|
||||
</button>`;
|
||||
};
|
||||
|
||||
private readonly _scrollInputToTop = () => {
|
||||
const { inlineEditor } = this.context;
|
||||
const { scrollContainer, scrollTopOffset } = this.context.config.mobile;
|
||||
|
||||
let container = null;
|
||||
let containerScrollTop = 0;
|
||||
if (typeof scrollContainer === 'string') {
|
||||
container = document.querySelector(scrollContainer);
|
||||
containerScrollTop = container?.scrollTop ?? 0;
|
||||
} else if (scrollContainer instanceof HTMLElement) {
|
||||
container = scrollContainer;
|
||||
containerScrollTop = scrollContainer.scrollTop;
|
||||
} else if (scrollContainer === window) {
|
||||
container = window;
|
||||
containerScrollTop = scrollContainer.scrollY;
|
||||
} else {
|
||||
container = getViewportElement(this.context.std.host);
|
||||
containerScrollTop = container?.scrollTop ?? 0;
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
if (typeof scrollTopOffset === 'function') {
|
||||
offset = scrollTopOffset();
|
||||
} else {
|
||||
offset = scrollTopOffset ?? 0;
|
||||
}
|
||||
|
||||
if (!inlineEditor.rootElement || !container) return;
|
||||
container.scrollTo({
|
||||
top:
|
||||
inlineEditor.rootElement.getBoundingClientRect().top +
|
||||
containerScrollTop -
|
||||
offset,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _updateLinkedDocGroup = async () => {
|
||||
if (this._updateLinkedDocGroupAbortController) {
|
||||
this._updateLinkedDocGroupAbortController.abort();
|
||||
}
|
||||
this._updateLinkedDocGroupAbortController = new AbortController();
|
||||
this._linkedDocGroup$.value = await this.context.config.getMenus(
|
||||
this._query ?? '',
|
||||
() => {
|
||||
this.context.close();
|
||||
cleanSpecifiedTail(
|
||||
this.context.std,
|
||||
this.context.inlineEditor,
|
||||
this.context.triggerKey + (this._query ?? '')
|
||||
);
|
||||
},
|
||||
this.context.std.host,
|
||||
this.context.inlineEditor,
|
||||
this._updateLinkedDocGroupAbortController.signal
|
||||
);
|
||||
};
|
||||
|
||||
private _updateLinkedDocGroupAbortController: AbortController | null = null;
|
||||
|
||||
private get _query() {
|
||||
return getTextContentFromInlineRange(
|
||||
this.context.inlineEditor,
|
||||
this.context.startRange
|
||||
);
|
||||
}
|
||||
|
||||
get keyboard() {
|
||||
return this.context.std.get(VirtualKeyboardProvider);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
const { inlineEditor, close } = this.context;
|
||||
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
|
||||
// prevent editor blur when click menu
|
||||
this._disposables.addFromEvent(this, 'pointerdown', e => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// close menu when click outside
|
||||
this.disposables.addFromEvent(
|
||||
window,
|
||||
'pointerdown',
|
||||
e => {
|
||||
if (e.target === this) return;
|
||||
close();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
// bind some key events
|
||||
{
|
||||
const { eventSource } = inlineEditor;
|
||||
if (!eventSource) return;
|
||||
|
||||
const keydownObserverAbortController = new AbortController();
|
||||
this._disposables.add(() => keydownObserverAbortController.abort());
|
||||
|
||||
createKeydownObserver({
|
||||
target: eventSource,
|
||||
signal: keydownObserverAbortController.signal,
|
||||
onInput: isComposition => {
|
||||
if (isComposition) {
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
} else {
|
||||
const subscription = inlineEditor.slots.renderComplete.subscribe(
|
||||
() => {
|
||||
subscription.unsubscribe();
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
onDelete: () => {
|
||||
const subscription = inlineEditor.slots.renderComplete.subscribe(
|
||||
() => {
|
||||
subscription.unsubscribe();
|
||||
const curRange = inlineEditor.getInlineRange();
|
||||
|
||||
if (!this.context.startRange || !curRange) return;
|
||||
|
||||
if (curRange.index < this.context.startRange.index) {
|
||||
this.context.close();
|
||||
}
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
}
|
||||
);
|
||||
},
|
||||
onConfirm: () => {
|
||||
this._firstActionItem?.action()?.catch(console.error);
|
||||
},
|
||||
onAbort: () => {
|
||||
this.context.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._scrollInputToTop();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const groups = this._linkedDocGroup$.value;
|
||||
if (groups.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
this._firstActionItem = resolveSignal(groups[0].items)[0];
|
||||
|
||||
this.style.bottom = `${this.keyboard.height$.value}px`;
|
||||
|
||||
return html`
|
||||
${join(groups.map(this._renderGroup), html`<div class="divider"></div>`)}
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: LinkedDocContext;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor rootComponent!: PageRootBlockComponent;
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, unsafeCSS } from 'lit';
|
||||
|
||||
export const linkedDocWidgetStyles = css`
|
||||
.input-mask {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const linkedDocPopoverStyles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.linked-doc-popover {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
font-size: var(--affine-font-base);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
gap: 4px;
|
||||
|
||||
background: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
|
||||
border-radius: 4px;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
|
||||
.linked-doc-popover icon-button {
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.linked-doc-popover .group-title {
|
||||
color: var(--affine-text-secondary-color);
|
||||
padding: 0 8px;
|
||||
height: 30px;
|
||||
font-size: var(--affine-font-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.linked-doc-popover .group-title .loading-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.linked-doc-popover .group-title .loading-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.linked-doc-popover .divider {
|
||||
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
}
|
||||
|
||||
.group icon-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.linked-doc-popover .group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
${scrollbarStyle('.linked-doc-popover')}
|
||||
`;
|
||||
|
||||
export const mobileLinkedDocMenuStyles = css`
|
||||
:host {
|
||||
height: 220px;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
overflow-y: auto;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
|
||||
--border-style: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
|
||||
border-radius: 12px 12px 0px 0px;
|
||||
border-top: var(--border-style);
|
||||
border-right: var(--border-style);
|
||||
border-left: var(--border-style);
|
||||
background: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
box-shadow: 0px -3px 10px 0px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
:host::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
}
|
||||
|
||||
.mobile-linked-doc-menu-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
padding: 11px 20px;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
border: none;
|
||||
background: inherit;
|
||||
|
||||
> svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
}
|
||||
|
||||
.text {
|
||||
overflow: hidden;
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
text-align: justify;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 17px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
letter-spacing: -0.43px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-linked-doc-menu-item:active {
|
||||
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||
}
|
||||
`;
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Signal } from '@preact/signals-core';
|
||||
|
||||
export function resolveSignal<T>(data: T | Signal<T>): T {
|
||||
return data instanceof Signal ? data.value : data;
|
||||
}
|
||||
Reference in New Issue
Block a user