mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 12:36:24 +08:00
refactor(editor): unify directories naming (#11516)
**Directory Structure Changes** - Renamed multiple block-related directories by removing the "block-" prefix: - `block-attachment` → `attachment` - `block-bookmark` → `bookmark` - `block-callout` → `callout` - `block-code` → `code` - `block-data-view` → `data-view` - `block-database` → `database` - `block-divider` → `divider` - `block-edgeless-text` → `edgeless-text` - `block-embed` → `embed`
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import type { FrameBlockModel, RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||
|
||||
export const EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET =
|
||||
'edgeless-navigator-black-background';
|
||||
export class EdgelessNavigatorBlackBackgroundWidget extends WidgetComponent<RootBlockModel> {
|
||||
static override styles = css`
|
||||
.edgeless-navigator-black-background {
|
||||
background-color: black;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background-color: transparent;
|
||||
box-shadow: 0 0 0 5000px black;
|
||||
}
|
||||
`;
|
||||
|
||||
private _blackBackground = false;
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
private get _slots() {
|
||||
return this.std.get(EdgelessLegacySlotIdentifier);
|
||||
}
|
||||
|
||||
private _tryLoadBlackBackground() {
|
||||
const value = this.std
|
||||
.get(EditPropsStore)
|
||||
.getStorage('presentBlackBackground');
|
||||
this._blackBackground = value ?? true;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { _disposables, gfx } = this;
|
||||
_disposables.add(
|
||||
this._slots.navigatorFrameChanged.subscribe(frame => {
|
||||
this.frame = frame;
|
||||
})
|
||||
);
|
||||
|
||||
_disposables.add(
|
||||
this._slots.navigatorSettingUpdated.subscribe(({ blackBackground }) => {
|
||||
if (blackBackground !== undefined) {
|
||||
this.std
|
||||
.get(EditPropsStore)
|
||||
.setStorage('presentBlackBackground', blackBackground);
|
||||
|
||||
this._blackBackground = blackBackground;
|
||||
|
||||
this.show =
|
||||
blackBackground &&
|
||||
this.gfx.tool.currentToolOption$.peek().type === 'frameNavigator';
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
_disposables.add(
|
||||
effect(() => {
|
||||
const tool = gfx.tool.currentToolName$.value;
|
||||
|
||||
if (tool !== 'frameNavigator') {
|
||||
this.show = false;
|
||||
} else {
|
||||
this.show = this._blackBackground;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
_disposables.add(
|
||||
this._slots.fullScreenToggled.subscribe(
|
||||
() =>
|
||||
setTimeout(() => {
|
||||
this.requestUpdate();
|
||||
}, 500) // wait for full screen animation
|
||||
)
|
||||
);
|
||||
|
||||
this._tryLoadBlackBackground();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { frame, show, gfx } = this;
|
||||
|
||||
if (!show || !frame) return nothing;
|
||||
|
||||
const bound = Bound.deserialize(frame.xywh);
|
||||
const zoom = gfx.viewport.zoom;
|
||||
const width = bound.w * zoom;
|
||||
const height = bound.h * zoom;
|
||||
const [x, y] = gfx.viewport.toViewCoord(bound.x, bound.y);
|
||||
|
||||
return html` <style>
|
||||
.edgeless-navigator-black-background {
|
||||
width: ${width}px;
|
||||
height: ${height}px;
|
||||
top: ${y}px;
|
||||
left: ${x}px;
|
||||
}
|
||||
</style>
|
||||
<div class="edgeless-navigator-black-background"></div>`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor frame: FrameBlockModel | undefined = undefined;
|
||||
|
||||
@state()
|
||||
private accessor show = false;
|
||||
}
|
||||
|
||||
export const edgelessNavigatorBgWidget = WidgetViewExtension(
|
||||
'affine:page',
|
||||
EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET,
|
||||
literal`${unsafeStatic(EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET)}`
|
||||
);
|
||||
@@ -0,0 +1,89 @@
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { WidgetComponent } from '@blocksuite/std';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
|
||||
export const AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET =
|
||||
'affine-edgeless-zoom-toolbar-widget';
|
||||
|
||||
export class AffineEdgelessZoomToolbarWidget extends WidgetComponent<
|
||||
RootBlockModel,
|
||||
EdgelessRootBlockComponent
|
||||
> {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 12px;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@container viewport (width <= 1200px) {
|
||||
edgeless-zoom-toolbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container viewport (width > 1200px) {
|
||||
zoom-bar-toggle-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
get edgeless() {
|
||||
return this.block;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const currentTool = this.edgeless?.gfx.tool.currentToolName$.value;
|
||||
|
||||
if (currentTool !== 'frameNavigator') {
|
||||
this._hide = false;
|
||||
}
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { disposables, std } = this;
|
||||
const slots = std.get(EdgelessLegacySlotIdentifier);
|
||||
|
||||
disposables.add(
|
||||
slots.navigatorSettingUpdated.subscribe(({ hideToolbar }) => {
|
||||
if (hideToolbar !== undefined) {
|
||||
this._hide = hideToolbar;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this._hide || !this.edgeless) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<edgeless-zoom-toolbar .edgeless=${this.edgeless}></edgeless-zoom-toolbar>
|
||||
<zoom-bar-toggle-button
|
||||
.edgeless=${this.edgeless}
|
||||
></zoom-bar-toggle-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _hide = false;
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { createLitPortal } from '@blocksuite/affine-components/portal';
|
||||
import { stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
|
||||
import { offset } from '@floating-ui/dom';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
|
||||
export class ZoomBarToggleButton extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
}
|
||||
.toggle-button {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
edgeless-zoom-toolbar {
|
||||
position: absolute;
|
||||
bottom: initial;
|
||||
}
|
||||
`;
|
||||
|
||||
private _abortController: AbortController | null = null;
|
||||
|
||||
private _closeZoomMenu() {
|
||||
if (this._abortController && !this._abortController.signal.aborted) {
|
||||
this._abortController.abort();
|
||||
this._abortController = null;
|
||||
this._showPopper = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleZoomMenu() {
|
||||
if (this._abortController && !this._abortController.signal.aborted) {
|
||||
this._closeZoomMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
this._abortController = new AbortController();
|
||||
this._abortController.signal.addEventListener('abort', () => {
|
||||
this._showPopper = false;
|
||||
});
|
||||
createLitPortal({
|
||||
template: html`<edgeless-zoom-toolbar
|
||||
.edgeless=${this.edgeless}
|
||||
.layout=${'vertical'}
|
||||
></edgeless-zoom-toolbar>`,
|
||||
container: this._toggleButton,
|
||||
computePosition: {
|
||||
referenceElement: this._toggleButton,
|
||||
placement: 'top',
|
||||
middleware: [offset(4)],
|
||||
autoUpdate: true,
|
||||
},
|
||||
abortController: this._abortController,
|
||||
closeOnClickAway: true,
|
||||
});
|
||||
this._showPopper = true;
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._closeZoomMenu();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { disposables } = this;
|
||||
disposables.add(
|
||||
this.edgeless.slots.readonlyUpdated.subscribe(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.edgeless.doc.readonly) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="toggle-button" @pointerdown=${stopPropagation}>
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${'Toggle Zoom Tool Bar'}
|
||||
.tipPosition=${'right'}
|
||||
.active=${this._showPopper}
|
||||
.arrow=${false}
|
||||
.activeMode=${'background'}
|
||||
.iconContainerPadding=${6}
|
||||
.iconSize=${'24px'}
|
||||
@click=${() => this._toggleZoomMenu()}
|
||||
>
|
||||
${MoreHorizontalIcon()}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _showPopper = false;
|
||||
|
||||
@query('.toggle-button')
|
||||
private accessor _toggleButton!: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { MinusIcon, PlusIcon, ViewBarIcon } from '@blocksuite/icons/lit';
|
||||
import { ZOOM_STEP } from '@blocksuite/std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
|
||||
export class EdgelessZoomToolbar extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.edgeless-zoom-toolbar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
fill: currentcolor;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.edgeless-zoom-toolbar-container.horizantal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.edgeless-zoom-toolbar-container.vertical {
|
||||
flex-direction: column;
|
||||
width: 40px;
|
||||
background-color: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
border: 1px solid var(--affine-border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.edgeless-zoom-toolbar-container[level='second'] {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.edgeless-zoom-toolbar-container[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.zoom-percent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
color: var(--affine-icon-color);
|
||||
background-color: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
}
|
||||
|
||||
.zoom-percent:hover {
|
||||
color: var(--affine-primary-color);
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.zoom-percent[disabled] {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
color: var(--affine-text-disable-color);
|
||||
}
|
||||
`;
|
||||
|
||||
get edgelessService() {
|
||||
return this.edgeless.service;
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.edgeless.gfx;
|
||||
}
|
||||
|
||||
get edgelessTool() {
|
||||
return this.edgeless.gfx.tool.currentToolOption$.peek();
|
||||
}
|
||||
|
||||
get locked() {
|
||||
return this.edgelessService.locked;
|
||||
}
|
||||
|
||||
get viewport() {
|
||||
return this.edgelessService.viewport;
|
||||
}
|
||||
|
||||
get zoom() {
|
||||
if (!this.viewport) {
|
||||
console.error('Something went wrong, viewport is not available');
|
||||
return 1;
|
||||
}
|
||||
return this.viewport.zoom;
|
||||
}
|
||||
|
||||
constructor(edgeless: EdgelessRootBlockComponent) {
|
||||
super();
|
||||
this.edgeless = edgeless;
|
||||
}
|
||||
|
||||
private _isVerticalBar() {
|
||||
return this.layout === 'vertical';
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
this.edgeless.gfx.tool.currentToolName$.value;
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { disposables } = this;
|
||||
disposables.add(
|
||||
this.edgeless.service.viewport.viewportUpdated.subscribe(() =>
|
||||
this.requestUpdate()
|
||||
)
|
||||
);
|
||||
disposables.add(
|
||||
this.edgeless.slots.readonlyUpdated.subscribe(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.edgeless.doc.readonly) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const formattedZoom = `${Math.round(this.zoom * 100)}%`;
|
||||
const classes = `edgeless-zoom-toolbar-container ${this.layout}`;
|
||||
const locked = this.locked;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${classes}
|
||||
@dblclick=${stopPropagation}
|
||||
@mousedown=${stopPropagation}
|
||||
@mouseup=${stopPropagation}
|
||||
@pointerdown=${stopPropagation}
|
||||
>
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${'Fit to screen'}
|
||||
.tipPosition=${this._isVerticalBar() ? 'right' : 'top-end'}
|
||||
.arrow=${!this._isVerticalBar()}
|
||||
@click=${() => this.gfx.fitToScreen()}
|
||||
.iconContainerPadding=${4}
|
||||
.iconSize=${'24px'}
|
||||
.disabled=${locked}
|
||||
>
|
||||
${ViewBarIcon()}
|
||||
</edgeless-tool-icon-button>
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${'Zoom out'}
|
||||
.tipPosition=${this._isVerticalBar() ? 'right' : 'top'}
|
||||
.arrow=${!this._isVerticalBar()}
|
||||
@click=${() => this.edgelessService.setZoomByStep(-ZOOM_STEP)}
|
||||
.iconContainerPadding=${4}
|
||||
.iconSize=${'24px'}
|
||||
.disabled=${locked}
|
||||
>
|
||||
${MinusIcon()}
|
||||
</edgeless-tool-icon-button>
|
||||
<button
|
||||
class="zoom-percent"
|
||||
@click=${() => this.viewport.smoothZoom(1)}
|
||||
.disabled=${locked}
|
||||
>
|
||||
${formattedZoom}
|
||||
</button>
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${'Zoom in'}
|
||||
.tipPosition=${this._isVerticalBar() ? 'right' : 'top'}
|
||||
.arrow=${!this._isVerticalBar()}
|
||||
@click=${() => this.edgelessService.setZoomByStep(ZOOM_STEP)}
|
||||
.iconContainerPadding=${4}
|
||||
.iconSize=${'24px'}
|
||||
.disabled=${locked}
|
||||
>
|
||||
${PlusIcon()}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor layout: 'horizontal' | 'vertical' = 'horizontal';
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export { AffineEdgelessZoomToolbarWidget } from './edgeless-zoom-toolbar/index.js';
|
||||
export { AffineInnerModalWidget } from './inner-modal/inner-modal.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';
|
||||
export { AffineFrameTitleWidget } from '@blocksuite/affine-widget-frame-title';
|
||||
@@ -0,0 +1,58 @@
|
||||
import { WidgetComponent } from '@blocksuite/std';
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
type FloatingElement,
|
||||
type ReferenceElement,
|
||||
size,
|
||||
} from '@floating-ui/dom';
|
||||
import { nothing } from 'lit';
|
||||
|
||||
export const AFFINE_INNER_MODAL_WIDGET = 'affine-inner-modal-widget';
|
||||
|
||||
export class AffineInnerModalWidget extends WidgetComponent {
|
||||
private _getTarget?: () => ReferenceElement;
|
||||
|
||||
get target(): ReferenceElement {
|
||||
if (this._getTarget) {
|
||||
return this._getTarget();
|
||||
}
|
||||
return document.body;
|
||||
}
|
||||
|
||||
open(
|
||||
modal: FloatingElement,
|
||||
ops: { onClose?: () => void }
|
||||
): { close(): void } {
|
||||
const cancel = autoUpdate(this.target, modal, () => {
|
||||
computePosition(this.target, modal, {
|
||||
middleware: [
|
||||
size({
|
||||
apply: ({ rects }) => {
|
||||
Object.assign(modal.style, {
|
||||
left: `${rects.reference.x}px`,
|
||||
top: `${rects.reference.y}px`,
|
||||
width: `${rects.reference.width}px`,
|
||||
height: `${rects.reference.height}px`,
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
}).catch(console.error);
|
||||
});
|
||||
const close = () => {
|
||||
modal.remove();
|
||||
ops.onClose?.();
|
||||
cancel();
|
||||
};
|
||||
return { close };
|
||||
}
|
||||
|
||||
override render() {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
setTarget(fn: () => ReferenceElement) {
|
||||
this._getTarget = fn;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
AFFINE_KEYBOARD_TOOLBAR_WIDGET,
|
||||
AffineKeyboardToolbarWidget,
|
||||
} from './index.js';
|
||||
import {
|
||||
AFFINE_KEYBOARD_TOOL_PANEL,
|
||||
AffineKeyboardToolPanel,
|
||||
} from './keyboard-tool-panel.js';
|
||||
import {
|
||||
AFFINE_KEYBOARD_TOOLBAR,
|
||||
AffineKeyboardToolbar,
|
||||
} from './keyboard-toolbar.js';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
AFFINE_KEYBOARD_TOOLBAR_WIDGET,
|
||||
AffineKeyboardToolbarWidget
|
||||
);
|
||||
customElements.define(AFFINE_KEYBOARD_TOOLBAR, AffineKeyboardToolbar);
|
||||
customElements.define(AFFINE_KEYBOARD_TOOL_PANEL, AffineKeyboardToolPanel);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_KEYBOARD_TOOLBAR]: AffineKeyboardToolbar;
|
||||
[AFFINE_KEYBOARD_TOOL_PANEL]: AffineKeyboardToolPanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
Heading5Icon,
|
||||
Heading6Icon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { html } from 'lit';
|
||||
|
||||
export function HeadingIcon(i: 1 | 2 | 3 | 4 | 5 | 6) {
|
||||
switch (i) {
|
||||
case 1:
|
||||
return Heading1Icon();
|
||||
case 2:
|
||||
return Heading2Icon();
|
||||
case 3:
|
||||
return Heading3Icon();
|
||||
case 4:
|
||||
return Heading4Icon();
|
||||
case 5:
|
||||
return Heading5Icon();
|
||||
case 6:
|
||||
return Heading6Icon();
|
||||
default:
|
||||
return Heading1Icon();
|
||||
}
|
||||
}
|
||||
|
||||
export const HighLightDuotoneIcon = (color: string) =>
|
||||
html`<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M5.8291 16.441L7.91757 18.5295L6.57811 19.8689C6.53119 19.9158 6.46406 19.9364 6.3989 19.9239L3.37036 19.3412C3.21285 19.3109 3.15331 19.1168 3.26673 19.0034L5.8291 16.441Z"
|
||||
fill="${color}"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M19.0095 3.63759C17.9526 2.58067 16.26 2.516 15.1255 3.48919L7.32135 10.1837C6.35438 11.0132 6.05275 12.3823 6.58163 13.5414L6.73501 13.8775L5.67697 14.9356C5.30169 15.3108 5.30169 15.9193 5.67697 16.2946L8.06379 18.6814C8.43907 19.0567 9.04752 19.0567 9.4228 18.6814L10.4808 17.6234L10.8171 17.7768C11.9761 18.3057 13.3452 18.0041 14.1747 17.0371L20.8692 9.23294C21.8424 8.09846 21.7778 6.40588 20.7208 5.34896L19.0095 3.63759ZM16.1021 4.62769C16.6415 4.16498 17.4463 4.19572 17.9488 4.69825L19.6602 6.40962C20.1627 6.91215 20.1935 7.7169 19.7307 8.25631L14.6424 14.188L10.1704 9.71604L16.1021 4.62769ZM9.02857 10.6955L8.29798 11.3222C7.83822 11.7166 7.6948 12.3676 7.94627 12.9187L8.29785 13.6892C8.4348 13.9893 8.37947 14.3544 8.13372 14.6001L7.11878 15.6151L8.74329 17.2396L9.75812 16.2247C10.004 15.9789 10.3691 15.9236 10.6693 16.0606L11.4398 16.4122C11.9908 16.6636 12.6418 16.5202 13.0362 16.0605L13.6629 15.3299L9.02857 10.6955Z"
|
||||
fill="${cssVarV2('icon/primary')}"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const TextColorIcon = (color: string) =>
|
||||
html`<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.0627 6.16255C14.385 5.30291 15.2068 4.7334 16.1249 4.7334C17.043 4.7334 17.8648 5.30291 18.1872 6.16255L23.7279 20.9378C23.9219 21.455 23.6599 22.0314 23.1427 22.2253C22.6256 22.4192 22.0492 22.1572 21.8553 21.6401L20.2289 17.3031H12.021L10.3946 21.6401C10.2007 22.1572 9.62428 22.4192 9.10716 22.2253C8.59004 22.0314 8.32803 21.455 8.52195 20.9378L14.0627 6.16255ZM12.771 15.3031H19.4789L16.3146 6.8648C16.2849 6.78576 16.2094 6.7334 16.1249 6.7334C16.0405 6.7334 15.965 6.78576 15.9353 6.8648L12.771 15.3031Z"
|
||||
fill="${cssVarV2('icon/primary')}"
|
||||
/>
|
||||
<rect
|
||||
x="5.45837"
|
||||
y="24"
|
||||
width="21.3333"
|
||||
height="3.33333"
|
||||
rx="1"
|
||||
fill=${color}
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const TextBackgroundDuotoneIcon = (color: string) =>
|
||||
html`<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.57507 7.33336C4.57507 5.60287 5.97791 4.20003 7.70841 4.20003H25.0417C26.7722 4.20003 28.1751 5.60287 28.1751 7.33336V24.6667C28.1751 26.3972 26.7722 27.8 25.0417 27.8H7.70841C5.97791 27.8 4.57507 26.3972 4.57507 24.6667V7.33336Z"
|
||||
fill="${color}"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.57495 7.33333C4.57495 5.60284 5.97779 4.2 7.70828 4.2H25.0416C26.7721 4.2 28.175 5.60284 28.175 7.33333V24.6667C28.175 26.3972 26.7721 27.8 25.0416 27.8H7.70828C5.97779 27.8 4.57495 26.3972 4.57495 24.6667V7.33333ZM7.70828 5.13333C6.49326 5.13333 5.50828 6.1183 5.50828 7.33333V24.6667C5.50828 25.8817 6.49326 26.8667 7.70828 26.8667H25.0416C26.2566 26.8667 27.2416 25.8817 27.2416 24.6667V7.33333C27.2416 6.1183 26.2566 5.13333 25.0416 5.13333H7.70828Z"
|
||||
fill="black"
|
||||
fill-opacity="0.22"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.5379 10.0064C14.8251 9.24064 15.5571 8.73332 16.375 8.73332C17.1928 8.73332 17.9249 9.24064 18.2121 10.0064L22.6446 21.8266C22.8386 22.3438 22.5766 22.9202 22.0594 23.1141C21.5423 23.308 20.9659 23.046 20.772 22.5289L19.5196 19.1891H13.2304L11.978 22.5289C11.7841 23.046 11.2076 23.308 10.6905 23.1141C10.1734 22.9202 9.9114 22.3438 10.1053 21.8266L14.5379 10.0064ZM13.9804 17.1891H18.7696L16.375 10.8035L13.9804 17.1891Z"
|
||||
fill="${cssVarV2('text/primary')}"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const FigmaDuotoneIcon = html`<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g id="Figma_Duotone">
|
||||
<path
|
||||
id="Vector"
|
||||
d="M8.41842 22.5027C10.3047 22.5027 11.8356 20.9719 11.8356 19.0856V15.6685H8.41842C6.53216 15.6685 5.00128 17.1993 5.00128 19.0856C5.00128 20.9719 6.53216 22.5027 8.41842 22.5027Z"
|
||||
fill="#0ACF83"
|
||||
/>
|
||||
<path
|
||||
id="Vector_2"
|
||||
d="M5.00128 12.2514C5.00128 10.3651 6.53216 8.83423 8.41842 8.83423H11.8356V15.6685H8.41842C6.53216 15.6685 5.00128 14.1376 5.00128 12.2514Z"
|
||||
fill="#A259FF"
|
||||
/>
|
||||
<path
|
||||
id="Vector_3"
|
||||
d="M5.00146 5.41714C5.00146 3.53088 6.53234 2 8.4186 2H11.8357V8.83428H8.4186C6.53234 8.83428 5.00146 7.3034 5.00146 5.41714Z"
|
||||
fill="#F24E1E"
|
||||
/>
|
||||
<path
|
||||
id="Vector_4"
|
||||
d="M11.8356 2H15.2527C17.139 2 18.6699 3.53088 18.6699 5.41714C18.6699 7.3034 17.139 8.83428 15.2527 8.83428H11.8356V2Z"
|
||||
fill="#FF7262"
|
||||
/>
|
||||
<path
|
||||
id="Vector_5"
|
||||
d="M18.6699 12.2514C18.6699 14.1376 17.139 15.6685 15.2527 15.6685C13.3665 15.6685 11.8356 14.1376 11.8356 12.2514C11.8356 10.3651 13.3665 8.83423 15.2527 8.83423C17.139 8.83423 18.6699 10.3651 18.6699 12.2514Z"
|
||||
fill="#1ABCFE"
|
||||
/>
|
||||
</g>
|
||||
</svg> `;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { getDocTitleByEditorHost } from '@blocksuite/affine-fragment-doc-title';
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
FeatureFlagService,
|
||||
VirtualKeyboardProvider,
|
||||
type VirtualKeyboardProviderWithAction,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { WidgetComponent } from '@blocksuite/std';
|
||||
import { effect, signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
|
||||
import type { PageRootBlockComponent } from '../../page/page-root-block.js';
|
||||
import { RootBlockConfigExtension } from '../../root-config.js';
|
||||
import { defaultKeyboardToolbarConfig } from './config.js';
|
||||
|
||||
export * from './config.js';
|
||||
|
||||
export const AFFINE_KEYBOARD_TOOLBAR_WIDGET = 'affine-keyboard-toolbar-widget';
|
||||
|
||||
export class AffineKeyboardToolbarWidget extends WidgetComponent<
|
||||
RootBlockModel,
|
||||
PageRootBlockComponent
|
||||
> {
|
||||
private readonly _close = (blur: boolean) => {
|
||||
if (blur) {
|
||||
if (document.activeElement === this._docTitle?.inlineEditorContainer) {
|
||||
this._docTitle?.inlineEditor?.setInlineRange(null);
|
||||
this._docTitle?.inlineEditor?.eventSource?.blur();
|
||||
} else if (document.activeElement === this.block?.rootComponent) {
|
||||
this.std.selection.clear();
|
||||
}
|
||||
}
|
||||
this._show$.value = false;
|
||||
};
|
||||
|
||||
private readonly _show$ = signal(false);
|
||||
|
||||
private _initialInputMode: string = '';
|
||||
|
||||
get keyboard(): VirtualKeyboardProviderWithAction {
|
||||
return {
|
||||
// fallback keyboard actions
|
||||
show: () => {
|
||||
const rootComponent = this.block?.rootComponent;
|
||||
if (rootComponent && rootComponent === document.activeElement) {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
}
|
||||
},
|
||||
hide: () => {
|
||||
const rootComponent = this.block?.rootComponent;
|
||||
if (rootComponent && rootComponent === document.activeElement) {
|
||||
rootComponent.inputMode = 'none';
|
||||
}
|
||||
},
|
||||
...this.std.get(VirtualKeyboardProvider),
|
||||
};
|
||||
}
|
||||
|
||||
private get _docTitle() {
|
||||
return getDocTitleByEditorHost(this.std.host);
|
||||
}
|
||||
|
||||
get config() {
|
||||
return {
|
||||
...defaultKeyboardToolbarConfig,
|
||||
...this.std.getOptional(RootBlockConfigExtension.identifier)
|
||||
?.keyboardToolbar,
|
||||
};
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
const rootComponent = this.block?.rootComponent;
|
||||
if (rootComponent) {
|
||||
this._initialInputMode = rootComponent.inputMode;
|
||||
this.disposables.add(() => {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
});
|
||||
this.disposables.addFromEvent(rootComponent, 'focus', () => {
|
||||
this._show$.value = true;
|
||||
});
|
||||
this.disposables.addFromEvent(rootComponent, 'blur', () => {
|
||||
this._show$.value = false;
|
||||
});
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
// recover input mode when keyboard toolbar is hidden
|
||||
if (!this._show$.value) {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (this._docTitle) {
|
||||
const { inlineEditorContainer } = this._docTitle;
|
||||
this.disposables.addFromEvent(inlineEditorContainer, 'focus', () => {
|
||||
this._show$.value = true;
|
||||
});
|
||||
this.disposables.addFromEvent(inlineEditorContainer, 'blur', () => {
|
||||
this._show$.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (
|
||||
this.doc.readonly ||
|
||||
!IS_MOBILE ||
|
||||
!this.doc
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_mobile_keyboard_toolbar')
|
||||
)
|
||||
return nothing;
|
||||
|
||||
if (!this._show$.value) return nothing;
|
||||
|
||||
if (!this.block?.rootComponent) return nothing;
|
||||
|
||||
return html`<blocksuite-portal
|
||||
.shadowDom=${false}
|
||||
.template=${html`<affine-keyboard-toolbar
|
||||
.keyboard=${this.keyboard}
|
||||
.config=${this.config}
|
||||
.rootComponent=${this.block.rootComponent}
|
||||
.close=${this._close}
|
||||
></affine-keyboard-toolbar>`}
|
||||
></blocksuite-portal>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_KEYBOARD_TOOLBAR_WIDGET]: AffineKeyboardToolbarWidget;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
PropTypes,
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/std';
|
||||
import { html, nothing, type PropertyValues } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type {
|
||||
KeyboardIconType,
|
||||
KeyboardToolbarActionItem,
|
||||
KeyboardToolbarContext,
|
||||
KeyboardToolPanelConfig,
|
||||
KeyboardToolPanelGroup,
|
||||
} from './config.js';
|
||||
import { keyboardToolPanelStyles } from './styles.js';
|
||||
|
||||
export const AFFINE_KEYBOARD_TOOL_PANEL = 'affine-keyboard-tool-panel';
|
||||
|
||||
@requiredProperties({
|
||||
context: PropTypes.object,
|
||||
})
|
||||
export class AffineKeyboardToolPanel extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = keyboardToolPanelStyles;
|
||||
|
||||
private readonly _handleItemClick = (item: KeyboardToolbarActionItem) => {
|
||||
if (item.disableWhen && item.disableWhen(this.context)) return;
|
||||
if (item.action) {
|
||||
Promise.resolve(item.action(this.context)).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
private _renderGroup(group: KeyboardToolPanelGroup) {
|
||||
const items = group.items.filter(
|
||||
item => item.showWhen?.(this.context) ?? true
|
||||
);
|
||||
|
||||
return html`<div class="keyboard-tool-panel-group">
|
||||
<div class="keyboard-tool-panel-group-header">${group.name}</div>
|
||||
<div class="keyboard-tool-panel-group-item-container">
|
||||
${repeat(
|
||||
items,
|
||||
item => item.name,
|
||||
item => this._renderItem(item)
|
||||
)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderIcon(icon: KeyboardIconType) {
|
||||
return typeof icon === 'function' ? icon(this.context) : icon;
|
||||
}
|
||||
|
||||
private _renderItem(item: KeyboardToolbarActionItem) {
|
||||
return html`<div class="keyboard-tool-panel-item">
|
||||
<button @click=${() => this._handleItemClick(item)}>
|
||||
${this._renderIcon(item.icon)}
|
||||
</button>
|
||||
<span>${item.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.config) return nothing;
|
||||
|
||||
const groups = this.config.groups
|
||||
.map(group => (typeof group === 'function' ? group(this.context) : group))
|
||||
.filter((group): group is KeyboardToolPanelGroup => group !== null);
|
||||
|
||||
return repeat(
|
||||
groups,
|
||||
group => group.name,
|
||||
group => this._renderGroup(group)
|
||||
);
|
||||
}
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has('height')) {
|
||||
this.style.height = `${this.height}px`;
|
||||
if (this.height === 0) {
|
||||
this.style.padding = '0';
|
||||
} else {
|
||||
this.style.padding = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor config: KeyboardToolPanelConfig | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: KeyboardToolbarContext;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor height = 0;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
|
||||
import { type VirtualKeyboardProviderWithAction } from '@blocksuite/affine-shared/services';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ArrowLeftBigIcon, KeyboardIcon } from '@blocksuite/icons/lit';
|
||||
import {
|
||||
PropTypes,
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/std';
|
||||
import { effect, type Signal, signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
|
||||
import { PageRootBlockComponent } from '../../page/page-root-block';
|
||||
import type {
|
||||
KeyboardIconType,
|
||||
KeyboardToolbarConfig,
|
||||
KeyboardToolbarContext,
|
||||
KeyboardToolbarItem,
|
||||
KeyboardToolPanelConfig,
|
||||
} from './config';
|
||||
import { PositionController } from './position-controller';
|
||||
import { keyboardToolbarStyles } from './styles';
|
||||
import {
|
||||
isKeyboardSubToolBarConfig,
|
||||
isKeyboardToolBarActionItem,
|
||||
isKeyboardToolPanelConfig,
|
||||
} from './utils';
|
||||
|
||||
export const AFFINE_KEYBOARD_TOOLBAR = 'affine-keyboard-toolbar';
|
||||
|
||||
@requiredProperties({
|
||||
config: PropTypes.object,
|
||||
rootComponent: PropTypes.instanceOf(PageRootBlockComponent),
|
||||
})
|
||||
export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = keyboardToolbarStyles;
|
||||
|
||||
/** This field records the panel static height same as the virtual keyboard height */
|
||||
panelHeight$ = signal(0);
|
||||
|
||||
positionController = new PositionController(this);
|
||||
|
||||
get std() {
|
||||
return this.rootComponent.std;
|
||||
}
|
||||
|
||||
get panelOpened() {
|
||||
return this._currentPanelIndex$.value !== -1;
|
||||
}
|
||||
|
||||
private readonly _closeToolPanel = () => {
|
||||
if (!this.panelOpened) return;
|
||||
|
||||
this._currentPanelIndex$.value = -1;
|
||||
this.keyboard.show();
|
||||
};
|
||||
|
||||
private readonly _currentPanelIndex$ = signal(-1);
|
||||
|
||||
private readonly _goPrevToolbar = () => {
|
||||
if (!this._isSubToolbarOpened) return;
|
||||
|
||||
if (this.panelOpened) this._closeToolPanel();
|
||||
|
||||
this._path$.value = this._path$.value.slice(0, -1);
|
||||
};
|
||||
|
||||
private readonly _handleItemClick = (
|
||||
item: KeyboardToolbarItem,
|
||||
index: number
|
||||
) => {
|
||||
if (isKeyboardToolBarActionItem(item)) {
|
||||
item.action &&
|
||||
Promise.resolve(item.action(this._context)).catch(console.error);
|
||||
} else if (isKeyboardSubToolBarConfig(item)) {
|
||||
this._closeToolPanel();
|
||||
this._path$.value = [...this._path$.value, index];
|
||||
} else if (isKeyboardToolPanelConfig(item)) {
|
||||
if (this._currentPanelIndex$.value === index) {
|
||||
this._closeToolPanel();
|
||||
} else {
|
||||
this._currentPanelIndex$.value = index;
|
||||
this.keyboard.hide();
|
||||
this._scrollCurrentBlockIntoView();
|
||||
}
|
||||
}
|
||||
this._lastActiveItem$.value = item;
|
||||
};
|
||||
|
||||
private readonly _lastActiveItem$ = signal<KeyboardToolbarItem | null>(null);
|
||||
|
||||
private readonly _path$ = signal<number[]>([]);
|
||||
|
||||
private readonly _scrollCurrentBlockIntoView = () => {
|
||||
this.std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(({ selectedModels }) => {
|
||||
if (!selectedModels?.length) return;
|
||||
|
||||
const block = this.std.view.getBlock(selectedModels[0].id);
|
||||
if (!block) return;
|
||||
|
||||
const { y: y1 } = this.getBoundingClientRect();
|
||||
const { bottom: y2 } = block.getBoundingClientRect();
|
||||
const gap = 8;
|
||||
|
||||
if (y2 < y1 + gap) return;
|
||||
|
||||
scrollTo({
|
||||
top: window.scrollY + y2 - y1 + gap,
|
||||
behavior: 'instant',
|
||||
});
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
private get _context(): KeyboardToolbarContext {
|
||||
return {
|
||||
std: this.std,
|
||||
rootComponent: this.rootComponent,
|
||||
closeToolbar: (blur = false) => {
|
||||
this.close(blur);
|
||||
},
|
||||
closeToolPanel: () => {
|
||||
this._closeToolPanel();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private get _currentPanelConfig(): KeyboardToolPanelConfig | null {
|
||||
if (!this.panelOpened) return null;
|
||||
|
||||
const result = this._currentToolbarItems[this._currentPanelIndex$.value];
|
||||
|
||||
return isKeyboardToolPanelConfig(result) ? result : null;
|
||||
}
|
||||
|
||||
private get _currentToolbarItems(): KeyboardToolbarItem[] {
|
||||
let items = this.config.items;
|
||||
for (const index of this._path$.value) {
|
||||
if (isKeyboardSubToolBarConfig(items[index])) {
|
||||
items = items[index].items;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return items.filter(item =>
|
||||
isKeyboardToolBarActionItem(item)
|
||||
? (item.showWhen?.(this._context) ?? true)
|
||||
: true
|
||||
);
|
||||
}
|
||||
|
||||
private get _isSubToolbarOpened() {
|
||||
return this._path$.value.length > 0;
|
||||
}
|
||||
|
||||
private _renderIcon(icon: KeyboardIconType) {
|
||||
return typeof icon === 'function' ? icon(this._context) : icon;
|
||||
}
|
||||
|
||||
private _renderItem(item: KeyboardToolbarItem, index: number) {
|
||||
let icon = item.icon;
|
||||
let style = styleMap({});
|
||||
const disabled =
|
||||
('disableWhen' in item && item.disableWhen?.(this._context)) ?? false;
|
||||
|
||||
if (isKeyboardToolBarActionItem(item)) {
|
||||
const background =
|
||||
typeof item.background === 'function'
|
||||
? item.background(this._context)
|
||||
: item.background;
|
||||
if (background)
|
||||
style = styleMap({
|
||||
background: background,
|
||||
});
|
||||
} else if (isKeyboardToolPanelConfig(item)) {
|
||||
const { activeIcon, activeBackground } = item;
|
||||
const active = this._currentPanelIndex$.value === index;
|
||||
|
||||
if (active && activeIcon) icon = activeIcon;
|
||||
if (active && activeBackground)
|
||||
style = styleMap({ background: activeBackground });
|
||||
}
|
||||
|
||||
return html`<icon-button
|
||||
size="36px"
|
||||
style=${style}
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
this._handleItemClick(item, index);
|
||||
}}
|
||||
>
|
||||
${this._renderIcon(icon)}
|
||||
</icon-button>`;
|
||||
}
|
||||
|
||||
private _renderItems() {
|
||||
if (document.activeElement !== this.rootComponent)
|
||||
return html`<div class="item-container"></div>`;
|
||||
|
||||
const goPrevToolbarAction = when(
|
||||
this._isSubToolbarOpened,
|
||||
() =>
|
||||
html`<icon-button size="36px" @click=${this._goPrevToolbar}>
|
||||
${ArrowLeftBigIcon()}
|
||||
</icon-button>`
|
||||
);
|
||||
|
||||
return html`<div class="item-container">
|
||||
${goPrevToolbarAction}
|
||||
${repeat(this._currentToolbarItems, (item, index) =>
|
||||
this._renderItem(item, index)
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderKeyboardButton() {
|
||||
return html`<div class="keyboard-container">
|
||||
<icon-button
|
||||
size="36px"
|
||||
@click=${() => {
|
||||
this.close(true);
|
||||
}}
|
||||
>
|
||||
${KeyboardIcon()}
|
||||
</icon-button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// prevent editor blur when click item in toolbar
|
||||
this.disposables.addFromEvent(this, 'pointerdown', e => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const std = this.rootComponent.std;
|
||||
std.selection.value;
|
||||
// wait cursor updated
|
||||
requestAnimationFrame(() => {
|
||||
this._scrollCurrentBlockIntoView();
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
this._watchAutoShow();
|
||||
}
|
||||
|
||||
private _watchAutoShow() {
|
||||
const autoShowSubToolbars: { path: number[]; signal: Signal<boolean> }[] =
|
||||
[];
|
||||
|
||||
const traverse = (item: KeyboardToolbarItem, path: number[]) => {
|
||||
if (isKeyboardSubToolBarConfig(item) && item.autoShow) {
|
||||
autoShowSubToolbars.push({
|
||||
path,
|
||||
signal: item.autoShow(this._context),
|
||||
});
|
||||
|
||||
item.items.forEach((subItem, index) => {
|
||||
traverse(subItem, [...path, index]);
|
||||
});
|
||||
}
|
||||
};
|
||||
this.config.items.forEach((item, index) => {
|
||||
traverse(item, [index]);
|
||||
});
|
||||
|
||||
const samePath = (a: number[], b: number[]) =>
|
||||
a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
|
||||
let prevPath = this._path$.peek();
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
autoShowSubToolbars.forEach(({ path, signal }) => {
|
||||
if (signal.value) {
|
||||
if (samePath(this._path$.peek(), path)) return;
|
||||
|
||||
prevPath = this._path$.peek();
|
||||
this._path$.value = path;
|
||||
} else {
|
||||
this._path$.value = prevPath;
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
// workaround for the virtual keyboard showing transition animation
|
||||
setTimeout(() => {
|
||||
this._scrollCurrentBlockIntoView();
|
||||
}, 700);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="keyboard-toolbar">
|
||||
${this._renderItems()}
|
||||
<div class="divider"></div>
|
||||
${this._renderKeyboardButton()}
|
||||
</div>
|
||||
<affine-keyboard-tool-panel
|
||||
.config=${this._currentPanelConfig}
|
||||
.context=${this._context}
|
||||
.height=${this.panelHeight$.value}
|
||||
></affine-keyboard-tool-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor keyboard!: VirtualKeyboardProviderWithAction;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor close: (blur: boolean) => void = () => {};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor config!: KeyboardToolbarConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor rootComponent!: PageRootBlockComponent;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type VirtualKeyboardProvider } from '@blocksuite/affine-shared/services';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import type { BlockStdScope, ShadowlessElement } from '@blocksuite/std';
|
||||
import { effect, type Signal } from '@preact/signals-core';
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
|
||||
/**
|
||||
* This controller is used to control the keyboard toolbar position
|
||||
*/
|
||||
export class PositionController implements ReactiveController {
|
||||
private readonly _disposables = new DisposableGroup();
|
||||
|
||||
host: ReactiveControllerHost &
|
||||
ShadowlessElement & {
|
||||
std: BlockStdScope;
|
||||
panelHeight$: Signal<number>;
|
||||
keyboard: VirtualKeyboardProvider;
|
||||
panelOpened: boolean;
|
||||
};
|
||||
|
||||
constructor(host: PositionController['host']) {
|
||||
(this.host = host).addController(this);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
const { keyboard } = this.host;
|
||||
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
if (keyboard.visible$.value) {
|
||||
this.host.panelHeight$.value = keyboard.height$.value;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.host.style.bottom = '0px';
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { css } from 'lit';
|
||||
|
||||
export const keyboardToolbarStyles = css`
|
||||
affine-keyboard-toolbar {
|
||||
position: fixed;
|
||||
display: block;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.keyboard-toolbar {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0px 8px;
|
||||
box-sizing: border-box;
|
||||
gap: 8px;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
|
||||
background-color: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
box-shadow: 0px -4px 10px 0px rgba(0, 0, 0, 0.05);
|
||||
|
||||
> div {
|
||||
padding-top: 4px;
|
||||
}
|
||||
> div:not(.item-container) {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
icon-button svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: 8px;
|
||||
padding-bottom: 0px;
|
||||
|
||||
icon-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.item-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 24px;
|
||||
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
}
|
||||
`;
|
||||
|
||||
export const keyboardToolPanelStyles = css`
|
||||
affine-keyboard-tool-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
padding: 16px 4px 8px 8px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
background-color: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
}
|
||||
|
||||
${scrollbarStyle('affine-keyboard-tool-panel')}
|
||||
|
||||
.keyboard-tool-panel-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.keyboard-tool-panel-group-header {
|
||||
color: ${unsafeCSSVarV2('text/secondary')};
|
||||
|
||||
/* Footnote/Emphasized */
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 590;
|
||||
line-height: 18px; /* 138.462% */
|
||||
}
|
||||
|
||||
.keyboard-tool-panel-group-item-container {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
column-gap: 12px;
|
||||
row-gap: 12px;
|
||||
}
|
||||
|
||||
.keyboard-tool-panel-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
align-self: stretch;
|
||||
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
background: ${unsafeCSSVarV2('layer/background/secondary')};
|
||||
}
|
||||
|
||||
button:active {
|
||||
background: #00000012;
|
||||
}
|
||||
|
||||
button svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-x: hidden;
|
||||
color: ${unsafeCSSVarV2('text/secondary')};
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
KeyboardSubToolbarConfig,
|
||||
KeyboardToolbarActionItem,
|
||||
KeyboardToolbarItem,
|
||||
KeyboardToolPanelConfig,
|
||||
} from './config.js';
|
||||
|
||||
export function isKeyboardToolBarActionItem(
|
||||
item: KeyboardToolbarItem
|
||||
): item is KeyboardToolbarActionItem {
|
||||
return 'action' in item;
|
||||
}
|
||||
|
||||
export function isKeyboardSubToolBarConfig(
|
||||
item: KeyboardToolbarItem
|
||||
): item is KeyboardSubToolbarConfig {
|
||||
return 'items' in item;
|
||||
}
|
||||
|
||||
export function isKeyboardToolPanelConfig(
|
||||
item: KeyboardToolbarItem
|
||||
): item is KeyboardToolPanelConfig {
|
||||
return 'groups' in item;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date) {
|
||||
// yyyy-mm-dd
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const strTime = `${year}-${month}-${day}`;
|
||||
return strTime;
|
||||
}
|
||||
|
||||
export function formatTime(date: Date) {
|
||||
// mm-dd hh:mm
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const strTime = `${month}-${day} ${hours}:${minutes}`;
|
||||
return strTime;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
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';
|
||||
@@ -0,0 +1,16 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,323 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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')};
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Signal } from '@preact/signals-core';
|
||||
|
||||
export function resolveSignal<T>(data: T | Signal<T>): T {
|
||||
return data instanceof Signal ? data.value : data;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
type ModalButton = {
|
||||
text: string;
|
||||
type?: 'primary';
|
||||
onClick: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
type ModalOptions = {
|
||||
footer: null | ModalButton[];
|
||||
};
|
||||
|
||||
export class AffineCustomModal extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
z-index: calc(var(--affine-z-index-modal) + 3);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.modal-background {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
background-color: var(--affine-background-modal-color);
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-window {
|
||||
width: 70%;
|
||||
min-width: 500px;
|
||||
height: 80%;
|
||||
overflow-y: scroll;
|
||||
background-color: var(--affine-background-overlay-panel-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--affine-shadow-3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.modal-footer .button {
|
||||
align-items: center;
|
||||
background: var(--affine-white);
|
||||
border: 1px solid;
|
||||
border-color: var(--affine-border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--affine-text-primary-color);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: var(--affine-font-sm);
|
||||
font-weight: 500;
|
||||
justify-content: center;
|
||||
outline: 0;
|
||||
padding: 12px 18px;
|
||||
touch-action: manipulation;
|
||||
transition: all 0.3s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.modal-footer .primary {
|
||||
background: var(--affine-primary-color);
|
||||
border-color: var(--affine-black-10);
|
||||
box-shadow: var(--affine-button-inner-shadow);
|
||||
color: var(--affine-pure-white);
|
||||
}
|
||||
`;
|
||||
|
||||
onOpen!: (div: HTMLDivElement) => void;
|
||||
|
||||
options!: ModalOptions;
|
||||
|
||||
close() {
|
||||
this.remove();
|
||||
}
|
||||
|
||||
modalRef(modal: Element | undefined) {
|
||||
if (modal) this.onOpen?.(modal as HTMLDivElement);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { options } = this;
|
||||
|
||||
return html`<div class="modal-background">
|
||||
<div class="modal-window">
|
||||
<div class="modal-main" ${ref(this.modalRef)}></div>
|
||||
<div class="modal-footer">
|
||||
${options.footer
|
||||
? repeat(
|
||||
options.footer,
|
||||
button => button.text,
|
||||
button => html`
|
||||
<button
|
||||
class="button ${button.type ?? ''}"
|
||||
@click=${button.onClick}
|
||||
>
|
||||
${button.text}
|
||||
</button>
|
||||
`
|
||||
)
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
type CreateModalOption = ModalOptions & {
|
||||
entry: (div: HTMLDivElement) => void;
|
||||
};
|
||||
|
||||
export function createCustomModal(
|
||||
options: CreateModalOption,
|
||||
container: HTMLElement = document.body
|
||||
) {
|
||||
const modal = new AffineCustomModal();
|
||||
|
||||
modal.onOpen = options.entry;
|
||||
modal.options = options;
|
||||
|
||||
container.append(modal);
|
||||
|
||||
return modal;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { WidgetComponent } from '@blocksuite/std';
|
||||
import { nothing } from 'lit';
|
||||
|
||||
import { createCustomModal } from './custom-modal.js';
|
||||
|
||||
export const AFFINE_MODAL_WIDGET = 'affine-modal-widget';
|
||||
|
||||
export class AffineModalWidget extends WidgetComponent {
|
||||
open(options: Parameters<typeof createCustomModal>[0]) {
|
||||
return createCustomModal(options, this.ownerDocument.body);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_MODAL_WIDGET]: AffineModalWidget;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { NoteBlockModel, RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { ViewportElementProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
autoScroll,
|
||||
getScrollContainer,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
BlockComponent,
|
||||
BlockSelection,
|
||||
type PointerEventState,
|
||||
WidgetComponent,
|
||||
} from '@blocksuite/std';
|
||||
import { html, nothing } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
type Rect = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type BlockInfo = {
|
||||
element: BlockComponent;
|
||||
rect: Rect;
|
||||
};
|
||||
|
||||
export const AFFINE_PAGE_DRAGGING_AREA_WIDGET =
|
||||
'affine-page-dragging-area-widget';
|
||||
|
||||
export class AffinePageDraggingAreaWidget extends WidgetComponent<RootBlockModel> {
|
||||
static excludeFlavours: string[] = ['affine:note', 'affine:surface'];
|
||||
|
||||
private _dragging = false;
|
||||
|
||||
private _initialContainerOffset: {
|
||||
x: number;
|
||||
y: number;
|
||||
} = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
|
||||
private _initialScrollOffset: {
|
||||
top: number;
|
||||
left: number;
|
||||
} = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
|
||||
private _lastPointerState: PointerEventState | null = null;
|
||||
|
||||
private _rafID = 0;
|
||||
|
||||
private readonly _updateDraggingArea = (
|
||||
state: PointerEventState,
|
||||
shouldAutoScroll: boolean
|
||||
) => {
|
||||
const { x, y } = state;
|
||||
const { x: startX, y: startY } = state.start;
|
||||
|
||||
const { left: initScrollX, top: initScrollY } = this._initialScrollOffset;
|
||||
if (!this._viewport) {
|
||||
return;
|
||||
}
|
||||
const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = this._viewport;
|
||||
|
||||
const { x: initConX, y: initConY } = this._initialContainerOffset;
|
||||
const { x: conX, y: conY } = state.containerOffset;
|
||||
|
||||
const { left: viewportLeft, top: viewportTop } = this._viewport;
|
||||
let left = Math.min(
|
||||
startX + initScrollX + initConX - viewportLeft,
|
||||
x + scrollLeft + conX - viewportLeft
|
||||
);
|
||||
let right = Math.max(
|
||||
startX + initScrollX + initConX - viewportLeft,
|
||||
x + scrollLeft + conX - viewportLeft
|
||||
);
|
||||
let top = Math.min(
|
||||
startY + initScrollY + initConY - viewportTop,
|
||||
y + scrollTop + conY - viewportTop
|
||||
);
|
||||
let bottom = Math.max(
|
||||
startY + initScrollY + initConY - viewportTop,
|
||||
y + scrollTop + conY - viewportTop
|
||||
);
|
||||
|
||||
left = Math.max(left, conX - viewportLeft);
|
||||
right = Math.min(right, scrollWidth);
|
||||
top = Math.max(top, conY - viewportTop);
|
||||
bottom = Math.min(bottom, scrollHeight);
|
||||
|
||||
const userRect = {
|
||||
left,
|
||||
top,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
};
|
||||
this.rect = userRect;
|
||||
this._selectBlocksByRect({
|
||||
left: userRect.left + viewportLeft,
|
||||
top: userRect.top + viewportTop,
|
||||
width: userRect.width,
|
||||
height: userRect.height,
|
||||
});
|
||||
this._lastPointerState = state;
|
||||
|
||||
if (shouldAutoScroll && this.scrollContainer) {
|
||||
const rect = this.scrollContainer.getBoundingClientRect();
|
||||
const result = autoScroll(this.scrollContainer, state.raw.y - rect.top);
|
||||
if (!result) {
|
||||
this._clearRaf();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private get _allBlocksWithRect(): BlockInfo[] {
|
||||
if (!this._viewport) {
|
||||
return [];
|
||||
}
|
||||
const { scrollLeft, scrollTop } = this._viewport;
|
||||
|
||||
const getAllNodeFromTree = (): BlockComponent[] => {
|
||||
const blocks: BlockComponent[] = [];
|
||||
this.host.view.walkThrough(node => {
|
||||
const view = node;
|
||||
if (!(view instanceof BlockComponent)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
view.model.role !== 'root' &&
|
||||
!AffinePageDraggingAreaWidget.excludeFlavours.includes(
|
||||
view.model.flavour
|
||||
)
|
||||
) {
|
||||
blocks.push(view);
|
||||
}
|
||||
return;
|
||||
});
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const elements = getAllNodeFromTree();
|
||||
|
||||
return elements.map(element => {
|
||||
const bounding = element.getBoundingClientRect();
|
||||
return {
|
||||
element,
|
||||
rect: {
|
||||
left: bounding.left + scrollLeft,
|
||||
top: bounding.top + scrollTop,
|
||||
width: bounding.width,
|
||||
height: bounding.height,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private get _viewport() {
|
||||
return this.std.get(ViewportElementProvider).viewport;
|
||||
}
|
||||
|
||||
private get scrollContainer() {
|
||||
if (!this.block) {
|
||||
return null;
|
||||
}
|
||||
return getScrollContainer(this.block);
|
||||
}
|
||||
|
||||
private _clearRaf() {
|
||||
if (this._rafID) {
|
||||
cancelAnimationFrame(this._rafID);
|
||||
this._rafID = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private _selectBlocksByRect(userRect: Rect) {
|
||||
const selections = getSelectingBlockPaths(
|
||||
this._allBlocksWithRect,
|
||||
userRect
|
||||
).map(blockPath => {
|
||||
return this.host.selection.create(BlockSelection, {
|
||||
blockId: blockPath,
|
||||
});
|
||||
});
|
||||
|
||||
this.host.selection.setGroup('note', selections);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.handleEvent(
|
||||
'dragStart',
|
||||
ctx => {
|
||||
const state = ctx.get('pointerState');
|
||||
const { button } = state.raw;
|
||||
if (button !== 0) return;
|
||||
if (!isDragArea(state)) return;
|
||||
if (!this._viewport) return;
|
||||
|
||||
this._dragging = true;
|
||||
const { scrollLeft, scrollTop } = this._viewport;
|
||||
this._initialScrollOffset = {
|
||||
left: scrollLeft,
|
||||
top: scrollTop,
|
||||
};
|
||||
this._initialContainerOffset = {
|
||||
x: state.containerOffset.x,
|
||||
y: state.containerOffset.y,
|
||||
};
|
||||
|
||||
return true;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
|
||||
this.handleEvent(
|
||||
'dragMove',
|
||||
ctx => {
|
||||
this._clearRaf();
|
||||
if (!this._dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = ctx.get('pointerState');
|
||||
// TODO(@L-Sun) support drag area for touch device
|
||||
if (state.raw.pointerType === 'touch') return;
|
||||
|
||||
ctx.get('defaultState').event.preventDefault();
|
||||
|
||||
this._rafID = requestAnimationFrame(() => {
|
||||
this._updateDraggingArea(state, true);
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
|
||||
this.handleEvent(
|
||||
'dragEnd',
|
||||
() => {
|
||||
this._clearRaf();
|
||||
this._dragging = false;
|
||||
this.rect = null;
|
||||
this._initialScrollOffset = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
this._initialContainerOffset = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
this._lastPointerState = null;
|
||||
},
|
||||
{
|
||||
global: true,
|
||||
}
|
||||
);
|
||||
|
||||
this.handleEvent(
|
||||
'pointerMove',
|
||||
ctx => {
|
||||
if (this._dragging) {
|
||||
const state = ctx.get('pointerState');
|
||||
state.raw.preventDefault();
|
||||
}
|
||||
},
|
||||
{
|
||||
global: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this._clearRaf();
|
||||
this._disposables.dispose();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._disposables.addFromEvent(this.scrollContainer, 'scroll', () => {
|
||||
if (!this._dragging || !this._lastPointerState) return;
|
||||
|
||||
const state = this._lastPointerState;
|
||||
this._rafID = requestAnimationFrame(() => {
|
||||
this._updateDraggingArea(state, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const rect = this.rect;
|
||||
if (!rect) return nothing;
|
||||
|
||||
const style = {
|
||||
left: rect.left + 'px',
|
||||
top: rect.top + 'px',
|
||||
width: rect.width + 'px',
|
||||
height: rect.height + 'px',
|
||||
};
|
||||
return html`
|
||||
<style>
|
||||
.affine-page-dragging-area {
|
||||
position: absolute;
|
||||
background: var(--affine-hover-color);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<div class="affine-page-dragging-area" style=${styleMap(style)}></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor rect: Rect | null = null;
|
||||
}
|
||||
|
||||
function rectIntersects(a: Rect, b: Rect) {
|
||||
return (
|
||||
a.left < b.left + b.width &&
|
||||
a.left + a.width > b.left &&
|
||||
a.top < b.top + b.height &&
|
||||
a.top + a.height > b.top
|
||||
);
|
||||
}
|
||||
|
||||
function rectIncludesTopAndBottom(a: Rect, b: Rect) {
|
||||
return a.top <= b.top && a.top + a.height >= b.top + b.height;
|
||||
}
|
||||
|
||||
function filterBlockInfos(blockInfos: BlockInfo[], userRect: Rect) {
|
||||
const results: BlockInfo[] = [];
|
||||
for (const blockInfo of blockInfos) {
|
||||
const rect = blockInfo.rect;
|
||||
if (userRect.top + userRect.height < rect.top) break;
|
||||
|
||||
results.push(blockInfo);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function filterBlockInfosByParent(
|
||||
parentInfos: BlockInfo,
|
||||
userRect: Rect,
|
||||
filteredBlockInfos: BlockInfo[]
|
||||
) {
|
||||
const targetBlock = parentInfos.element;
|
||||
let results = [parentInfos];
|
||||
if (targetBlock.childElementCount > 0) {
|
||||
const childBlockInfos = targetBlock.childBlocks
|
||||
.map(el =>
|
||||
filteredBlockInfos.find(
|
||||
blockInfo => blockInfo.element.model.id === el.model.id
|
||||
)
|
||||
)
|
||||
.filter(block => block) as BlockInfo[];
|
||||
const firstIndex = childBlockInfos.findIndex(
|
||||
bl => rectIntersects(bl.rect, userRect) && bl.rect.top < userRect.top
|
||||
);
|
||||
const lastIndex = childBlockInfos.findIndex(
|
||||
bl =>
|
||||
rectIntersects(bl.rect, userRect) &&
|
||||
bl.rect.top + bl.rect.height > userRect.top + userRect.height
|
||||
);
|
||||
|
||||
if (firstIndex !== -1 && lastIndex !== -1) {
|
||||
results = childBlockInfos.slice(firstIndex, lastIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function getSelectingBlockPaths(blockInfos: BlockInfo[], userRect: Rect) {
|
||||
const filteredBlockInfos = filterBlockInfos(blockInfos, userRect);
|
||||
const len = filteredBlockInfos.length;
|
||||
const blockPaths: string[] = [];
|
||||
let singleTargetParentBlock: BlockInfo | null = null;
|
||||
let blocks: BlockInfo[] = [];
|
||||
if (len === 0) return blockPaths;
|
||||
|
||||
// To get the single target parent block info
|
||||
for (const block of filteredBlockInfos) {
|
||||
const rect = block.rect;
|
||||
|
||||
if (
|
||||
rectIntersects(userRect, rect) &&
|
||||
rectIncludesTopAndBottom(rect, userRect)
|
||||
) {
|
||||
singleTargetParentBlock = block;
|
||||
}
|
||||
}
|
||||
|
||||
if (singleTargetParentBlock) {
|
||||
blocks = filterBlockInfosByParent(
|
||||
singleTargetParentBlock,
|
||||
userRect,
|
||||
filteredBlockInfos
|
||||
);
|
||||
} else {
|
||||
// If there is no block contains the top and bottom of the userRect
|
||||
// Then get all the blocks that intersect with the userRect
|
||||
for (const block of filteredBlockInfos) {
|
||||
if (rectIntersects(userRect, block.rect)) {
|
||||
blocks.push(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out the blocks which parent is in the blocks
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
const parent = blocks[i].element.doc.getParent(block.element.model);
|
||||
const parentId = parent?.id;
|
||||
if (parentId) {
|
||||
const isParentInBlocks = blocks.some(
|
||||
block => block.element.model.id === parentId
|
||||
);
|
||||
if (!isParentInBlocks) {
|
||||
blockPaths.push(blocks[i].element.blockId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blockPaths;
|
||||
}
|
||||
|
||||
function isDragArea(e: PointerEventState) {
|
||||
const el = e.raw.target;
|
||||
if (!(el instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
const block = el.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`);
|
||||
if (!block) {
|
||||
return false;
|
||||
}
|
||||
return matchModels(block.model, [RootBlockModel, NoteBlockModel]);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_PAGE_DRAGGING_AREA_WIDGET]: AffinePageDraggingAreaWidget;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { WidgetComponent } from '@blocksuite/std';
|
||||
import { css, html } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export const AFFINE_VIEWPORT_OVERLAY_WIDGET = 'affine-viewport-overlay-widget';
|
||||
|
||||
export class AffineViewportOverlayWidget extends WidgetComponent<RootBlockModel> {
|
||||
static override styles = css`
|
||||
.affine-viewport-overlay-widget {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
z-index: calc(var(--affine-z-index-popover) - 1);
|
||||
}
|
||||
|
||||
.affine-viewport-overlay-widget.lock {
|
||||
pointer-events: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.handleEvent(
|
||||
'dragStart',
|
||||
() => {
|
||||
return this._lockViewport;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
this.handleEvent(
|
||||
'pointerDown',
|
||||
() => {
|
||||
return this._lockViewport;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
this.handleEvent(
|
||||
'click',
|
||||
() => {
|
||||
return this._lockViewport;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
}
|
||||
|
||||
lock() {
|
||||
this._lockViewport = true;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const classes = classMap({
|
||||
'affine-viewport-overlay-widget': true,
|
||||
lock: this._lockViewport,
|
||||
});
|
||||
const style = styleMap({
|
||||
width: `${this._lockViewport ? '100vw' : '0'}`,
|
||||
height: `${this._lockViewport ? '100%' : '0'}`,
|
||||
});
|
||||
return html` <div class=${classes} style=${style}></div> `;
|
||||
}
|
||||
|
||||
toggleLock() {
|
||||
this._lockViewport = !this._lockViewport;
|
||||
}
|
||||
|
||||
unlock() {
|
||||
this._lockViewport = false;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _lockViewport = false;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_VIEWPORT_OVERLAY_WIDGET]: AffineViewportOverlayWidget;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user