mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-18 10:31:50 +08:00
chore(editor): reorg packages (#10702)
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { WidgetComponent } from '@blocksuite/block-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.on(({ 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;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET]: AffineEdgelessZoomToolbarWidget;
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
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.on(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'zoom-bar-toggle-button': ZoomBarToggleButton;
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
import { stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { ZOOM_STEP } from '@blocksuite/block-std/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { MinusIcon, PlusIcon, ViewBarIcon } from '@blocksuite/icons/lit';
|
||||
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.on(() =>
|
||||
this.requestUpdate()
|
||||
)
|
||||
);
|
||||
disposables.add(
|
||||
this.edgeless.slots.readonlyUpdated.on(() => {
|
||||
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';
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-zoom-toolbar': EdgelessZoomToolbar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { MindmapElementModel } from '@blocksuite/affine-model';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { GfxModel } from '@blocksuite/block-std/gfx';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { FrameIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
|
||||
export class EdgelessAddFrameButton extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.label {
|
||||
padding-left: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _createFrame = () => {
|
||||
const frame = this.edgeless.service.frame.createFrameOnSelected();
|
||||
if (!frame) return;
|
||||
this.edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'context-menu',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'frame',
|
||||
});
|
||||
this.edgeless.surface.fitToViewport(Bound.deserialize(frame.xywh));
|
||||
};
|
||||
|
||||
protected override render() {
|
||||
return html`
|
||||
<editor-icon-button
|
||||
aria-label="Frame"
|
||||
.tooltip=${'Frame'}
|
||||
.labelHeight=${'20px'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${this._createFrame}
|
||||
>
|
||||
${FrameIcon()}<span class="label medium">Frame</span>
|
||||
</editor-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
}
|
||||
|
||||
export function renderAddFrameButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
elements: GfxModel[]
|
||||
) {
|
||||
if (elements.length < 2) return nothing;
|
||||
if (elements.some(e => e.group instanceof MindmapElementModel))
|
||||
return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-add-frame-button
|
||||
.edgeless=${edgeless}
|
||||
></edgeless-add-frame-button>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
GroupElementModel,
|
||||
MindmapElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { GfxModel } from '@blocksuite/block-std/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { GroupingIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
|
||||
export class EdgelessAddGroupButton extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.label {
|
||||
padding-left: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _createGroup = () => {
|
||||
this.edgeless.service.createGroupFromSelected();
|
||||
};
|
||||
|
||||
protected override render() {
|
||||
return html`
|
||||
<editor-icon-button
|
||||
aria-label="Group"
|
||||
.tooltip=${'Group'}
|
||||
.labelHeight=${'20px'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${this._createGroup}
|
||||
>
|
||||
${GroupingIcon()}<span class="label medium">Group</span>
|
||||
</editor-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
}
|
||||
|
||||
export function renderAddGroupButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
elements: GfxModel[]
|
||||
) {
|
||||
if (elements.length < 2) return nothing;
|
||||
if (elements[0] instanceof GroupElementModel) return nothing;
|
||||
if (elements.some(e => e.group instanceof MindmapElementModel))
|
||||
return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-add-group-button
|
||||
.edgeless=${edgeless}
|
||||
></edgeless-add-group-button>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import {
|
||||
autoArrangeElementsCommand,
|
||||
autoResizeElementsCommand,
|
||||
EdgelessCRUDIdentifier,
|
||||
updateXYWH,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { MindmapElementModel } from '@blocksuite/affine-model';
|
||||
import type { GfxModel } from '@blocksuite/block-std/gfx';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
AlignHorizontalCenterIcon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
AlignTopIcon,
|
||||
AlignVerticalCenterIcon,
|
||||
AutoTidyUpIcon,
|
||||
DistributeHorizontalIcon,
|
||||
DistributeVerticalIcon,
|
||||
ResizeTidyUpIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import { SmallArrowDownIcon } from './icons.js';
|
||||
|
||||
const enum Alignment {
|
||||
AutoArrange = 'Auto arrange',
|
||||
AutoResize = 'Resize & Align',
|
||||
Bottom = 'Align bottom',
|
||||
DistributeHorizontally = 'Distribute horizontally',
|
||||
DistributeVertically = 'Distribute vertically',
|
||||
Horizontally = 'Align horizontally',
|
||||
Left = 'Align left',
|
||||
Right = 'Align right',
|
||||
Top = 'Align top',
|
||||
Vertically = 'Align vertically',
|
||||
}
|
||||
|
||||
interface AlignmentIcon {
|
||||
name: Alignment;
|
||||
content: TemplateResult<1>;
|
||||
}
|
||||
|
||||
const iconSize = { width: '20px', height: '20px' };
|
||||
const HORIZONTAL_ALIGNMENT: AlignmentIcon[] = [
|
||||
{
|
||||
name: Alignment.Left,
|
||||
content: AlignLeftIcon(iconSize),
|
||||
},
|
||||
{
|
||||
name: Alignment.Horizontally,
|
||||
content: AlignHorizontalCenterIcon(iconSize),
|
||||
},
|
||||
{
|
||||
name: Alignment.Right,
|
||||
content: AlignRightIcon(iconSize),
|
||||
},
|
||||
{
|
||||
name: Alignment.DistributeHorizontally,
|
||||
content: DistributeHorizontalIcon(iconSize),
|
||||
},
|
||||
];
|
||||
|
||||
const VERTICAL_ALIGNMENT: AlignmentIcon[] = [
|
||||
{
|
||||
name: Alignment.Top,
|
||||
content: AlignTopIcon(iconSize),
|
||||
},
|
||||
{
|
||||
name: Alignment.Vertically,
|
||||
content: AlignVerticalCenterIcon(iconSize),
|
||||
},
|
||||
{
|
||||
name: Alignment.Bottom,
|
||||
content: AlignBottomIcon(iconSize),
|
||||
},
|
||||
{
|
||||
name: Alignment.DistributeVertically,
|
||||
content: DistributeVerticalIcon(iconSize),
|
||||
},
|
||||
];
|
||||
|
||||
const AUTO_ALIGNMENT: AlignmentIcon[] = [
|
||||
{
|
||||
name: Alignment.AutoArrange,
|
||||
content: AutoTidyUpIcon({ width: '20px', height: '20px' }),
|
||||
},
|
||||
{
|
||||
name: Alignment.AutoResize,
|
||||
content: ResizeTidyUpIcon({ width: '20px', height: '20px' }),
|
||||
},
|
||||
];
|
||||
|
||||
export class EdgelessAlignButton extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.align-menu-content {
|
||||
max-width: 120px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 2px;
|
||||
}
|
||||
.align-menu-separator {
|
||||
width: 120px;
|
||||
height: 1px;
|
||||
background-color: var(--affine-background-tertiary-color);
|
||||
}
|
||||
`;
|
||||
|
||||
private get elements() {
|
||||
return this.edgeless.service.selection.selectedElements;
|
||||
}
|
||||
|
||||
private _align(type: Alignment) {
|
||||
switch (type) {
|
||||
case Alignment.Left:
|
||||
this._alignLeft();
|
||||
break;
|
||||
case Alignment.Horizontally:
|
||||
this._alignHorizontally();
|
||||
break;
|
||||
case Alignment.Right:
|
||||
this._alignRight();
|
||||
break;
|
||||
case Alignment.DistributeHorizontally:
|
||||
this._alignDistributeHorizontally();
|
||||
break;
|
||||
case Alignment.Top:
|
||||
this._alignTop();
|
||||
break;
|
||||
case Alignment.Vertically:
|
||||
this._alignVertically();
|
||||
break;
|
||||
case Alignment.Bottom:
|
||||
this._alignBottom();
|
||||
break;
|
||||
case Alignment.DistributeVertically:
|
||||
this._alignDistributeVertically();
|
||||
break;
|
||||
case Alignment.AutoArrange:
|
||||
this.edgeless.std.command.exec(autoArrangeElementsCommand);
|
||||
break;
|
||||
case Alignment.AutoResize:
|
||||
this.edgeless.std.command.exec(autoResizeElementsCommand);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private _alignBottom() {
|
||||
const { elements } = this;
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const bottom = Math.max(...bounds.map(b => b.maxY));
|
||||
|
||||
elements.forEach((ele, index) => {
|
||||
const elementBound = bounds[index];
|
||||
const bound = Bound.deserialize(ele.xywh);
|
||||
const offset = bound.maxY - elementBound.maxY;
|
||||
bound.y = bottom - bound.h + offset;
|
||||
this._updateXYWH(ele, bound);
|
||||
});
|
||||
}
|
||||
|
||||
private _alignDistributeHorizontally() {
|
||||
const { elements } = this;
|
||||
|
||||
elements.sort((a, b) => a.elementBound.minX - b.elementBound.minX);
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const left = bounds[0].minX;
|
||||
const right = bounds[bounds.length - 1].maxX;
|
||||
|
||||
const totalWidth = right - left;
|
||||
const totalGap =
|
||||
totalWidth - elements.reduce((prev, ele) => prev + ele.elementBound.w, 0);
|
||||
const gap = totalGap / (elements.length - 1);
|
||||
let next = bounds[0].maxX + gap;
|
||||
for (let i = 1; i < elements.length - 1; i++) {
|
||||
const bound = Bound.deserialize(elements[i].xywh);
|
||||
bound.x = next + bounds[i].w / 2 - bound.w / 2;
|
||||
next += gap + bounds[i].w;
|
||||
this._updateXYWH(elements[i], bound);
|
||||
}
|
||||
}
|
||||
|
||||
private _alignDistributeVertically() {
|
||||
const { elements } = this;
|
||||
|
||||
elements.sort((a, b) => a.elementBound.minY - b.elementBound.minY);
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const top = bounds[0].minY;
|
||||
const bottom = bounds[bounds.length - 1].maxY;
|
||||
|
||||
const totalHeight = bottom - top;
|
||||
const totalGap =
|
||||
totalHeight -
|
||||
elements.reduce((prev, ele) => prev + ele.elementBound.h, 0);
|
||||
const gap = totalGap / (elements.length - 1);
|
||||
let next = bounds[0].maxY + gap;
|
||||
for (let i = 1; i < elements.length - 1; i++) {
|
||||
const bound = Bound.deserialize(elements[i].xywh);
|
||||
bound.y = next + bounds[i].h / 2 - bound.h / 2;
|
||||
next += gap + bounds[i].h;
|
||||
this._updateXYWH(elements[i], bound);
|
||||
}
|
||||
}
|
||||
|
||||
private _alignHorizontally() {
|
||||
const { elements } = this;
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const left = Math.min(...bounds.map(b => b.minX));
|
||||
const right = Math.max(...bounds.map(b => b.maxX));
|
||||
const centerX = (left + right) / 2;
|
||||
|
||||
elements.forEach(ele => {
|
||||
const bound = Bound.deserialize(ele.xywh);
|
||||
bound.x = centerX - bound.w / 2;
|
||||
this._updateXYWH(ele, bound);
|
||||
});
|
||||
}
|
||||
|
||||
private _alignLeft() {
|
||||
const { elements } = this;
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const left = Math.min(...bounds.map(b => b.minX));
|
||||
|
||||
elements.forEach((ele, index) => {
|
||||
const elementBound = bounds[index];
|
||||
const bound = Bound.deserialize(ele.xywh);
|
||||
const offset = bound.minX - elementBound.minX;
|
||||
bound.x = left + offset;
|
||||
this._updateXYWH(ele, bound);
|
||||
});
|
||||
}
|
||||
|
||||
private _alignRight() {
|
||||
const { elements } = this;
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const right = Math.max(...bounds.map(b => b.maxX));
|
||||
|
||||
elements.forEach((ele, index) => {
|
||||
const elementBound = bounds[index];
|
||||
const bound = Bound.deserialize(ele.xywh);
|
||||
const offset = bound.maxX - elementBound.maxX;
|
||||
bound.x = right - bound.w + offset;
|
||||
this._updateXYWH(ele, bound);
|
||||
});
|
||||
}
|
||||
|
||||
private _alignTop() {
|
||||
const { elements } = this;
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const top = Math.min(...bounds.map(b => b.minY));
|
||||
|
||||
elements.forEach((ele, index) => {
|
||||
const elementBound = bounds[index];
|
||||
const bound = Bound.deserialize(ele.xywh);
|
||||
const offset = bound.minY - elementBound.minY;
|
||||
bound.y = top + offset;
|
||||
this._updateXYWH(ele, bound);
|
||||
});
|
||||
}
|
||||
|
||||
private _alignVertically() {
|
||||
const { elements } = this;
|
||||
const bounds = elements.map(a => a.elementBound);
|
||||
const top = Math.min(...bounds.map(b => b.minY));
|
||||
const bottom = Math.max(...bounds.map(b => b.maxY));
|
||||
const centerY = (top + bottom) / 2;
|
||||
|
||||
elements.forEach(ele => {
|
||||
const bound = Bound.deserialize(ele.xywh);
|
||||
bound.y = centerY - bound.h / 2;
|
||||
this._updateXYWH(ele, bound);
|
||||
});
|
||||
}
|
||||
|
||||
private _updateXYWH(ele: GfxModel, bound: Bound) {
|
||||
const { updateElement } = this.edgeless.std.get(EdgelessCRUDIdentifier);
|
||||
const { updateBlock } = this.edgeless.doc;
|
||||
updateXYWH(ele, bound, updateElement, updateBlock);
|
||||
}
|
||||
|
||||
private renderIcons(icons: AlignmentIcon[]) {
|
||||
return html`
|
||||
${repeat(
|
||||
icons,
|
||||
(item, index) => item.name + index,
|
||||
({ name, content }) => {
|
||||
return html`
|
||||
<editor-icon-button
|
||||
aria-label=${name}
|
||||
.tooltip=${name}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() => this._align(name)}
|
||||
>
|
||||
${content}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._disposables.add(
|
||||
this.edgeless.service.selection.slots.updated.on(() =>
|
||||
this.requestUpdate()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Align objects"
|
||||
.tooltip=${'Align objects'}
|
||||
>
|
||||
${AlignLeftIcon(iconSize)}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div class="align-menu-content">
|
||||
${this.renderIcons(HORIZONTAL_ALIGNMENT)}
|
||||
${this.renderIcons(VERTICAL_ALIGNMENT)}
|
||||
<div class="align-menu-separator"></div>
|
||||
${this.renderIcons(AUTO_ALIGNMENT)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
}
|
||||
|
||||
export function renderAlignButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
elements: GfxModel[]
|
||||
) {
|
||||
if (elements.length < 2) return nothing;
|
||||
if (elements.some(e => e.group instanceof MindmapElementModel))
|
||||
return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-align-button .edgeless=${edgeless}></edgeless-align-button>
|
||||
`;
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
type AttachmentBlockComponent,
|
||||
attachmentViewDropdownMenu,
|
||||
} from '@blocksuite/affine-block-attachment';
|
||||
import { getEmbedCardIcons } from '@blocksuite/affine-block-embed';
|
||||
import {
|
||||
CaptionIcon,
|
||||
DownloadIcon,
|
||||
PaletteIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
|
||||
import type {
|
||||
AttachmentBlockModel,
|
||||
EmbedCardStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
ThemeProvider,
|
||||
ToolbarContext,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import type { TemplateResult } from 'lit';
|
||||
import { html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
|
||||
export class EdgelessChangeAttachmentButton extends WithDisposable(LitElement) {
|
||||
private readonly _download = () => {
|
||||
this._block?.download();
|
||||
};
|
||||
|
||||
private readonly _setCardStyle = (style: EmbedCardStyle) => {
|
||||
const bounds = Bound.deserialize(this.model.xywh);
|
||||
bounds.w = EMBED_CARD_WIDTH[style];
|
||||
bounds.h = EMBED_CARD_HEIGHT[style];
|
||||
const xywh = bounds.serialize();
|
||||
this.model.doc.updateBlock(this.model, { style, xywh });
|
||||
};
|
||||
|
||||
private readonly _showCaption = () => {
|
||||
this._block?.captionEditor?.show();
|
||||
};
|
||||
|
||||
private get _block() {
|
||||
const block = this.std.view.getBlock(this.model.id);
|
||||
if (!block) return null;
|
||||
return block as AttachmentBlockComponent;
|
||||
}
|
||||
|
||||
private get _doc() {
|
||||
return this.model.doc;
|
||||
}
|
||||
|
||||
private get _getCardStyleOptions(): {
|
||||
style: EmbedCardStyle;
|
||||
Icon: TemplateResult<1>;
|
||||
tooltip: string;
|
||||
}[] {
|
||||
const theme = this.std.get(ThemeProvider).theme;
|
||||
const { EmbedCardListIcon, EmbedCardCubeIcon } = getEmbedCardIcons(theme);
|
||||
return [
|
||||
{
|
||||
style: 'horizontalThin',
|
||||
Icon: EmbedCardListIcon,
|
||||
tooltip: 'Horizontal style',
|
||||
},
|
||||
{
|
||||
style: 'cubeThick',
|
||||
Icon: EmbedCardCubeIcon,
|
||||
tooltip: 'Vertical style',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
get std() {
|
||||
return this.edgeless.std;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return join(
|
||||
[
|
||||
this.model.style === 'pdf'
|
||||
? null
|
||||
: html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Card style"
|
||||
.tooltip=${'Card style'}
|
||||
>
|
||||
${PaletteIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<card-style-panel
|
||||
.value=${this.model.style}
|
||||
.options=${this._getCardStyleOptions}
|
||||
.onSelect=${this._setCardStyle}
|
||||
>
|
||||
</card-style-panel>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
|
||||
// TODO(@fundon): should remove it when refactoring the element toolbar
|
||||
attachmentViewDropdownMenu(new ToolbarContext(this.std)),
|
||||
|
||||
html`
|
||||
<editor-icon-button
|
||||
aria-label="Download"
|
||||
.tooltip=${'Download'}
|
||||
?disabled=${this._doc.readonly}
|
||||
@click=${this._download}
|
||||
>
|
||||
${DownloadIcon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
html`
|
||||
<editor-icon-button
|
||||
aria-label="Add caption"
|
||||
.tooltip=${'Add caption'}
|
||||
class="change-attachment-button caption"
|
||||
?disabled=${this._doc.readonly}
|
||||
@click=${this._showCaption}
|
||||
>
|
||||
${CaptionIcon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
].filter(button => button !== null),
|
||||
renderToolbarSeparator
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor model!: AttachmentBlockModel;
|
||||
}
|
||||
|
||||
export function renderAttachmentButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
attachments?: AttachmentBlockModel[]
|
||||
) {
|
||||
if (attachments?.length !== 1) return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-change-attachment-button
|
||||
.model=${attachments[0]}
|
||||
.edgeless=${edgeless}
|
||||
></edgeless-change-attachment-button>
|
||||
`;
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import type {
|
||||
EdgelessColorPickerButton,
|
||||
PickColorEvent,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import {
|
||||
packColor,
|
||||
packColorsWithColorScheme,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import type {
|
||||
BrushElementModel,
|
||||
BrushProps,
|
||||
ColorScheme,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
DefaultTheme,
|
||||
LineWidth,
|
||||
resolveColor,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { html, LitElement, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
import countBy from 'lodash-es/countBy';
|
||||
import maxBy from 'lodash-es/maxBy';
|
||||
|
||||
import type { LineWidthEvent } from '../../edgeless/components/panel/line-width-panel.js';
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
|
||||
function getMostCommonColor(
|
||||
elements: BrushElementModel[],
|
||||
colorScheme: ColorScheme
|
||||
): string {
|
||||
const colors = countBy(elements, (ele: BrushElementModel) =>
|
||||
resolveColor(ele.color, colorScheme)
|
||||
);
|
||||
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
|
||||
return max
|
||||
? (max[0] as string)
|
||||
: resolveColor(DefaultTheme.black, colorScheme);
|
||||
}
|
||||
|
||||
function getMostCommonSize(elements: BrushElementModel[]): LineWidth {
|
||||
const sizes = countBy(elements, ele => ele.lineWidth);
|
||||
const max = maxBy(Object.entries(sizes), ([_k, count]) => count);
|
||||
return max ? (Number(max[0]) as LineWidth) : LineWidth.Four;
|
||||
}
|
||||
|
||||
function notEqual<K extends keyof BrushProps>(key: K, value: BrushProps[K]) {
|
||||
return (element: BrushElementModel) => element[key] !== value;
|
||||
}
|
||||
|
||||
export class EdgelessChangeBrushButton extends WithDisposable(LitElement) {
|
||||
private readonly _setBrushColor = ({ detail }: ColorEvent) => {
|
||||
const color = detail.value;
|
||||
this._setBrushProp('color', color);
|
||||
};
|
||||
|
||||
private readonly _setLineWidth = ({ detail: lineWidth }: LineWidthEvent) => {
|
||||
this._setBrushProp('lineWidth', lineWidth);
|
||||
};
|
||||
|
||||
pickColor = (e: PickColorEvent) => {
|
||||
const field = 'color';
|
||||
|
||||
if (e.type === 'pick') {
|
||||
const color = e.detail.value;
|
||||
this.elements.forEach(ele => {
|
||||
const props = packColor(field, color);
|
||||
this.crud.updateElement(ele.id, props);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.elements.forEach(ele =>
|
||||
ele[e.type === 'start' ? 'stash' : 'pop'](field)
|
||||
);
|
||||
};
|
||||
|
||||
get doc() {
|
||||
return this.edgeless.doc;
|
||||
}
|
||||
|
||||
get service() {
|
||||
return this.edgeless.service;
|
||||
}
|
||||
|
||||
get surface() {
|
||||
return this.edgeless.surface;
|
||||
}
|
||||
|
||||
get crud() {
|
||||
return this.edgeless.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
private _setBrushProp<K extends keyof BrushProps>(
|
||||
key: K,
|
||||
value: BrushProps[K]
|
||||
) {
|
||||
this.doc.captureSync();
|
||||
this.elements
|
||||
.filter(notEqual(key, value))
|
||||
.forEach(element =>
|
||||
this.crud.updateElement(element.id, { [key]: value })
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
|
||||
const elements = this.elements;
|
||||
const selectedColor = getMostCommonColor(elements, colorScheme);
|
||||
const selectedSize = getMostCommonSize(elements);
|
||||
|
||||
return html`
|
||||
<edgeless-line-width-panel
|
||||
.selectedSize=${selectedSize}
|
||||
@select=${this._setLineWidth}
|
||||
>
|
||||
</edgeless-line-width-panel>
|
||||
|
||||
<editor-toolbar-separator></editor-toolbar-separator>
|
||||
|
||||
${when(
|
||||
this.edgeless.doc
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_color_picker'),
|
||||
() => {
|
||||
const { type, colors } = packColorsWithColorScheme(
|
||||
colorScheme,
|
||||
selectedColor,
|
||||
elements[0].color
|
||||
);
|
||||
|
||||
return html`
|
||||
<edgeless-color-picker-button
|
||||
class="color"
|
||||
.label="${'Color'}"
|
||||
.pick=${this.pickColor}
|
||||
.color=${selectedColor}
|
||||
.colors=${colors}
|
||||
.colorType=${type}
|
||||
.theme=${colorScheme}
|
||||
>
|
||||
</edgeless-color-picker-button>
|
||||
`;
|
||||
},
|
||||
() => html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="Color" .tooltip=${'Color'}>
|
||||
<edgeless-color-button
|
||||
.color=${selectedColor}
|
||||
></edgeless-color-button>
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-color-panel
|
||||
.value=${selectedColor}
|
||||
.theme=${colorScheme}
|
||||
@select=${this._setBrushColor}
|
||||
>
|
||||
</edgeless-color-panel>
|
||||
</editor-menu-button>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
@query('edgeless-color-picker-button.color')
|
||||
accessor colorButton!: EdgelessColorPickerButton;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor elements: BrushElementModel[] = [];
|
||||
}
|
||||
|
||||
export function renderChangeBrushButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
elements?: BrushElementModel[]
|
||||
) {
|
||||
if (!elements?.length) return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-change-brush-button .elements=${elements} .edgeless=${edgeless}>
|
||||
</edgeless-change-brush-button>
|
||||
`;
|
||||
}
|
||||
+650
@@ -0,0 +1,650 @@
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import type {
|
||||
EdgelessColorPickerButton,
|
||||
PickColorEvent,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import {
|
||||
packColor,
|
||||
packColorsWithColorScheme,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
type ColorScheme,
|
||||
type ConnectorElementModel,
|
||||
type ConnectorElementProps,
|
||||
ConnectorEndpoint,
|
||||
type ConnectorLabelProps,
|
||||
ConnectorMode,
|
||||
DEFAULT_FRONT_END_POINT_STYLE,
|
||||
DEFAULT_REAR_END_POINT_STYLE,
|
||||
DefaultTheme,
|
||||
LineWidth,
|
||||
PointStyle,
|
||||
resolveColor,
|
||||
StrokeStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
AddTextIcon,
|
||||
ConnectorCIcon,
|
||||
ConnectorEIcon,
|
||||
ConnectorLIcon,
|
||||
EndPointArrowIcon,
|
||||
EndPointCircleIcon,
|
||||
EndPointDiamondIcon,
|
||||
EndPointTriangleIcon,
|
||||
FlipDirectionIcon,
|
||||
StartPointArrowIcon,
|
||||
StartPointCircleIcon,
|
||||
StartPointDiamondIcon,
|
||||
StartPointIcon,
|
||||
StartPointTriangleIcon,
|
||||
StyleGeneralIcon,
|
||||
StyleScribbleIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { html, LitElement, nothing, type TemplateResult } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
import countBy from 'lodash-es/countBy';
|
||||
import maxBy from 'lodash-es/maxBy';
|
||||
|
||||
import {
|
||||
type LineStyleEvent,
|
||||
LineStylesPanel,
|
||||
} from '../../edgeless/components/panel/line-styles-panel.js';
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import { mountConnectorLabelEditor } from '../../edgeless/utils/text.js';
|
||||
import { SmallArrowDownIcon } from './icons.js';
|
||||
|
||||
function getMostCommonColor(
|
||||
elements: ConnectorElementModel[],
|
||||
colorScheme: ColorScheme
|
||||
): string {
|
||||
const colors = countBy(elements, (ele: ConnectorElementModel) =>
|
||||
resolveColor(ele.stroke, colorScheme)
|
||||
);
|
||||
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
|
||||
return max
|
||||
? (max[0] as string)
|
||||
: resolveColor(DefaultTheme.connectorColor, colorScheme);
|
||||
}
|
||||
|
||||
function getMostCommonMode(
|
||||
elements: ConnectorElementModel[]
|
||||
): ConnectorMode | null {
|
||||
const modes = countBy(elements, ele => ele.mode);
|
||||
const max = maxBy(Object.entries(modes), ([_k, count]) => count);
|
||||
return max ? (Number(max[0]) as ConnectorMode) : null;
|
||||
}
|
||||
|
||||
function getMostCommonLineWidth(elements: ConnectorElementModel[]): LineWidth {
|
||||
const sizes = countBy(elements, ele => ele.strokeWidth);
|
||||
const max = maxBy(Object.entries(sizes), ([_k, count]) => count);
|
||||
return max ? (Number(max[0]) as LineWidth) : LineWidth.Four;
|
||||
}
|
||||
|
||||
export function getMostCommonLineStyle(
|
||||
elements: ConnectorElementModel[]
|
||||
): StrokeStyle {
|
||||
const sizes = countBy(elements, ele => ele.strokeStyle);
|
||||
const max = maxBy(Object.entries(sizes), ([_k, count]) => count);
|
||||
return max ? (max[0] as StrokeStyle) : StrokeStyle.Solid;
|
||||
}
|
||||
|
||||
function getMostCommonRough(elements: ConnectorElementModel[]): boolean {
|
||||
const { trueCount, falseCount } = elements.reduce(
|
||||
(counts, ele) => {
|
||||
if (ele.rough) {
|
||||
counts.trueCount++;
|
||||
} else {
|
||||
counts.falseCount++;
|
||||
}
|
||||
return counts;
|
||||
},
|
||||
{ trueCount: 0, falseCount: 0 }
|
||||
);
|
||||
|
||||
return trueCount > falseCount;
|
||||
}
|
||||
|
||||
function getMostCommonEndpointStyle(
|
||||
elements: ConnectorElementModel[],
|
||||
endpoint: ConnectorEndpoint,
|
||||
fallback: PointStyle
|
||||
): PointStyle {
|
||||
const field =
|
||||
endpoint === ConnectorEndpoint.Front
|
||||
? 'frontEndpointStyle'
|
||||
: 'rearEndpointStyle';
|
||||
const modes = countBy(elements, ele => ele[field]);
|
||||
const max = maxBy(Object.entries(modes), ([_k, count]) => count);
|
||||
return max ? (max[0] as PointStyle) : fallback;
|
||||
}
|
||||
|
||||
function notEqual<
|
||||
K extends keyof Omit<ConnectorElementProps, keyof ConnectorLabelProps>,
|
||||
>(key: K, value: ConnectorElementProps[K]) {
|
||||
return (element: ConnectorElementModel) => element[key] !== value;
|
||||
}
|
||||
|
||||
interface EndpointStyle {
|
||||
value: PointStyle;
|
||||
icon: TemplateResult<1>;
|
||||
}
|
||||
|
||||
const iconSize = { width: '20px', height: '20px' };
|
||||
const STYLE_LIST = [
|
||||
{
|
||||
name: 'General',
|
||||
value: false,
|
||||
icon: StyleGeneralIcon(iconSize),
|
||||
},
|
||||
{
|
||||
name: 'Scribbled',
|
||||
value: true,
|
||||
icon: StyleScribbleIcon(iconSize),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const STYLE_CHOOSE: [boolean, () => TemplateResult<1>][] = [
|
||||
[false, () => StyleGeneralIcon(iconSize)],
|
||||
[true, () => StyleScribbleIcon(iconSize)],
|
||||
] as const;
|
||||
|
||||
const FRONT_ENDPOINT_STYLE_LIST: EndpointStyle[] = [
|
||||
{
|
||||
value: PointStyle.None,
|
||||
icon: StartPointIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Arrow,
|
||||
icon: StartPointArrowIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Triangle,
|
||||
icon: StartPointTriangleIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Circle,
|
||||
icon: StartPointCircleIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Diamond,
|
||||
icon: StartPointDiamondIcon(),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const REAR_ENDPOINT_STYLE_LIST: EndpointStyle[] = [
|
||||
{
|
||||
value: PointStyle.Diamond,
|
||||
icon: EndPointDiamondIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Circle,
|
||||
icon: EndPointCircleIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Triangle,
|
||||
icon: EndPointTriangleIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.Arrow,
|
||||
icon: EndPointArrowIcon(),
|
||||
},
|
||||
{
|
||||
value: PointStyle.None,
|
||||
icon: StartPointIcon(),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const MODE_LIST = [
|
||||
{
|
||||
name: 'Curve',
|
||||
icon: ConnectorCIcon(),
|
||||
value: ConnectorMode.Curve,
|
||||
},
|
||||
{
|
||||
name: 'Elbowed',
|
||||
icon: ConnectorEIcon(),
|
||||
value: ConnectorMode.Orthogonal,
|
||||
},
|
||||
{
|
||||
name: 'Straight',
|
||||
icon: ConnectorLIcon(),
|
||||
value: ConnectorMode.Straight,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const MODE_CHOOSE: [ConnectorMode, () => TemplateResult<1>][] = [
|
||||
[ConnectorMode.Curve, () => ConnectorCIcon()],
|
||||
[ConnectorMode.Orthogonal, () => ConnectorEIcon()],
|
||||
[ConnectorMode.Straight, () => ConnectorLIcon()],
|
||||
] as const;
|
||||
|
||||
export class EdgelessChangeConnectorButton extends WithDisposable(LitElement) {
|
||||
get crud() {
|
||||
return this.edgeless.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
private readonly _setConnectorColor = (e: ColorEvent) => {
|
||||
const stroke = e.detail.value;
|
||||
this._setConnectorProp('stroke', stroke);
|
||||
};
|
||||
|
||||
private readonly _setConnectorStroke = ({ type, value }: LineStyleEvent) => {
|
||||
if (type === 'size') {
|
||||
this._setConnectorStrokeWidth(value);
|
||||
return;
|
||||
}
|
||||
this._setConnectorStrokeStyle(value);
|
||||
};
|
||||
|
||||
pickColor = (e: PickColorEvent) => {
|
||||
const field = 'stroke';
|
||||
|
||||
if (e.type === 'pick') {
|
||||
const color = e.detail.value;
|
||||
this.elements.forEach(ele => {
|
||||
const props = packColor(field, color);
|
||||
this.crud.updateElement(ele.id, props);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.elements.forEach(ele =>
|
||||
ele[e.type === 'start' ? 'stash' : 'pop'](field)
|
||||
);
|
||||
};
|
||||
|
||||
get doc() {
|
||||
return this.edgeless.doc;
|
||||
}
|
||||
|
||||
get service() {
|
||||
return this.edgeless.service;
|
||||
}
|
||||
|
||||
private _addLabel() {
|
||||
mountConnectorLabelEditor(this.elements[0], this.edgeless);
|
||||
}
|
||||
|
||||
private _flipEndpointStyle(
|
||||
frontEndpointStyle: PointStyle,
|
||||
rearEndpointStyle: PointStyle
|
||||
) {
|
||||
if (frontEndpointStyle === rearEndpointStyle) return;
|
||||
|
||||
this.elements.forEach(element =>
|
||||
this.crud.updateElement(element.id, {
|
||||
frontEndpointStyle: rearEndpointStyle,
|
||||
rearEndpointStyle: frontEndpointStyle,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _getEndpointIcon(list: EndpointStyle[], style: PointStyle) {
|
||||
return list.find(({ value }) => value === style)?.icon || StartPointIcon();
|
||||
}
|
||||
|
||||
private _setConnectorMode(mode: ConnectorMode) {
|
||||
this._setConnectorProp('mode', mode);
|
||||
}
|
||||
|
||||
private _setConnectorPointStyle(end: ConnectorEndpoint, style: PointStyle) {
|
||||
const props = {
|
||||
[end === ConnectorEndpoint.Front
|
||||
? 'frontEndpointStyle'
|
||||
: 'rearEndpointStyle']: style,
|
||||
};
|
||||
this.elements.forEach(element =>
|
||||
this.crud.updateElement(element.id, { ...props })
|
||||
);
|
||||
}
|
||||
|
||||
private _setConnectorProp<
|
||||
K extends keyof Omit<ConnectorElementProps, keyof ConnectorLabelProps>,
|
||||
>(key: K, value: ConnectorElementProps[K]) {
|
||||
this.doc.captureSync();
|
||||
this.elements
|
||||
.filter(notEqual(key, value))
|
||||
.forEach(element =>
|
||||
this.crud.updateElement(element.id, { [key]: value })
|
||||
);
|
||||
}
|
||||
|
||||
private _setConnectorRough(rough: boolean) {
|
||||
this._setConnectorProp('rough', rough);
|
||||
}
|
||||
|
||||
private _setConnectorStrokeStyle(strokeStyle: StrokeStyle) {
|
||||
this._setConnectorProp('strokeStyle', strokeStyle);
|
||||
}
|
||||
|
||||
private _setConnectorStrokeWidth(strokeWidth: number) {
|
||||
this._setConnectorProp('strokeWidth', strokeWidth);
|
||||
}
|
||||
|
||||
private _showAddButtonOrTextMenu() {
|
||||
if (this.elements.length === 1 && !this.elements[0].text) {
|
||||
return 'button';
|
||||
}
|
||||
if (!this.elements.some(e => !e.text)) {
|
||||
return 'menu';
|
||||
}
|
||||
return 'nothing';
|
||||
}
|
||||
|
||||
override render() {
|
||||
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
|
||||
const elements = this.elements;
|
||||
const selectedColor = getMostCommonColor(elements, colorScheme);
|
||||
const selectedMode = getMostCommonMode(elements);
|
||||
const selectedLineSize = getMostCommonLineWidth(elements);
|
||||
const selectedRough = getMostCommonRough(elements);
|
||||
const selectedLineStyle = getMostCommonLineStyle(elements);
|
||||
const selectedStartPointStyle = getMostCommonEndpointStyle(
|
||||
elements,
|
||||
ConnectorEndpoint.Front,
|
||||
DEFAULT_FRONT_END_POINT_STYLE
|
||||
);
|
||||
const selectedEndPointStyle = getMostCommonEndpointStyle(
|
||||
elements,
|
||||
ConnectorEndpoint.Rear,
|
||||
DEFAULT_REAR_END_POINT_STYLE
|
||||
);
|
||||
|
||||
return join(
|
||||
[
|
||||
when(
|
||||
this.edgeless.doc
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_color_picker'),
|
||||
() => {
|
||||
const { type, colors } = packColorsWithColorScheme(
|
||||
colorScheme,
|
||||
selectedColor,
|
||||
elements[0].stroke
|
||||
);
|
||||
|
||||
return html`
|
||||
<edgeless-color-picker-button
|
||||
class="stroke-color"
|
||||
.label="${'Stroke style'}"
|
||||
.pick=${this.pickColor}
|
||||
.color=${selectedColor}
|
||||
.colors=${colors}
|
||||
.colorType=${type}
|
||||
.theme=${colorScheme}
|
||||
.hollowCircle=${true}
|
||||
>
|
||||
<div
|
||||
slot="other"
|
||||
class="line-styles"
|
||||
style=${styleMap({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
${LineStylesPanel({
|
||||
selectedLineSize: selectedLineSize,
|
||||
selectedLineStyle: selectedLineStyle,
|
||||
onClick: this._setConnectorStroke,
|
||||
})}
|
||||
</div>
|
||||
<editor-toolbar-separator
|
||||
slot="separator"
|
||||
data-orientation="horizontal"
|
||||
></editor-toolbar-separator>
|
||||
</edgeless-color-picker-button>
|
||||
`;
|
||||
},
|
||||
() => html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Stroke style"
|
||||
.tooltip=${'Stroke style'}
|
||||
.iconSize=${'20px'}
|
||||
>
|
||||
<edgeless-color-button
|
||||
.color=${selectedColor}
|
||||
></edgeless-color-button>
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<stroke-style-panel
|
||||
.theme=${colorScheme}
|
||||
.strokeWidth=${selectedLineSize}
|
||||
.strokeStyle=${selectedLineStyle}
|
||||
.strokeColor=${selectedColor}
|
||||
.setStrokeStyle=${this._setConnectorStroke}
|
||||
.setStrokeColor=${this._setConnectorColor}
|
||||
>
|
||||
</stroke-style-panel>
|
||||
</editor-menu-button>
|
||||
`
|
||||
),
|
||||
|
||||
html`
|
||||
<editor-menu-button
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Style"
|
||||
.tooltip=${'Style'}
|
||||
.iconSize=${'20px'}
|
||||
>
|
||||
${choose(selectedRough, STYLE_CHOOSE)}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div>
|
||||
${repeat(
|
||||
STYLE_LIST,
|
||||
item => item.name,
|
||||
({ name, value, icon }) => html`
|
||||
<editor-icon-button
|
||||
aria-label=${name}
|
||||
.tooltip=${name}
|
||||
.active=${selectedRough === value}
|
||||
.activeMode=${'background'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() => this._setConnectorRough(value)}
|
||||
>
|
||||
${icon}
|
||||
</editor-icon-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
|
||||
html`
|
||||
<editor-menu-button
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Start point style"
|
||||
.tooltip=${'Start point style'}
|
||||
.iconSize=${'20px'}
|
||||
>
|
||||
${this._getEndpointIcon(
|
||||
FRONT_ENDPOINT_STYLE_LIST,
|
||||
selectedStartPointStyle
|
||||
)}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div>
|
||||
${repeat(
|
||||
FRONT_ENDPOINT_STYLE_LIST,
|
||||
item => item.value,
|
||||
({ value, icon }) => html`
|
||||
<editor-icon-button
|
||||
aria-label=${value}
|
||||
.tooltip=${value}
|
||||
.active=${selectedStartPointStyle === value}
|
||||
.activeMode=${'background'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() =>
|
||||
this._setConnectorPointStyle(
|
||||
ConnectorEndpoint.Front,
|
||||
value
|
||||
)}
|
||||
>
|
||||
${icon}
|
||||
</editor-icon-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
|
||||
<editor-icon-button
|
||||
aria-label="Flip direction"
|
||||
.tooltip=${'Flip direction'}
|
||||
.disabled=${false}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() =>
|
||||
this._flipEndpointStyle(
|
||||
selectedStartPointStyle,
|
||||
selectedEndPointStyle
|
||||
)}
|
||||
>
|
||||
${FlipDirectionIcon()}
|
||||
</editor-icon-button>
|
||||
|
||||
<editor-menu-button
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="End point style"
|
||||
.tooltip=${'End point style'}
|
||||
.iconSize=${'20px'}
|
||||
>
|
||||
${this._getEndpointIcon(
|
||||
REAR_ENDPOINT_STYLE_LIST,
|
||||
selectedEndPointStyle
|
||||
)}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div>
|
||||
${repeat(
|
||||
REAR_ENDPOINT_STYLE_LIST,
|
||||
item => item.value,
|
||||
({ value, icon }) => html`
|
||||
<editor-icon-button
|
||||
aria-label=${value}
|
||||
.tooltip=${value}
|
||||
.active=${selectedEndPointStyle === value}
|
||||
.activeMode=${'background'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() =>
|
||||
this._setConnectorPointStyle(
|
||||
ConnectorEndpoint.Rear,
|
||||
value
|
||||
)}
|
||||
>
|
||||
${icon}
|
||||
</editor-icon-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
|
||||
<editor-menu-button
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Shape"
|
||||
.tooltip=${'Connector shape'}
|
||||
.iconSize=${'20px'}
|
||||
>
|
||||
${choose(selectedMode, MODE_CHOOSE)}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div>
|
||||
${repeat(
|
||||
MODE_LIST,
|
||||
item => item.name,
|
||||
({ name, value, icon }) => html`
|
||||
<editor-icon-button
|
||||
aria-label=${name}
|
||||
.tooltip=${name}
|
||||
.active=${selectedMode === value}
|
||||
.activeMode=${'background'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() => this._setConnectorMode(value)}
|
||||
>
|
||||
${icon}
|
||||
</editor-icon-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
|
||||
choose<string, TemplateResult<1> | typeof nothing>(
|
||||
this._showAddButtonOrTextMenu(),
|
||||
[
|
||||
[
|
||||
'button',
|
||||
() => html`
|
||||
<editor-icon-button
|
||||
aria-label="Add text"
|
||||
.tooltip=${'Add text'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${this._addLabel}
|
||||
>
|
||||
${AddTextIcon()}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
],
|
||||
[
|
||||
'menu',
|
||||
() => html`
|
||||
<edgeless-change-text-menu
|
||||
.elementType=${'connector'}
|
||||
.elements=${this.elements}
|
||||
.edgeless=${this.edgeless}
|
||||
></edgeless-change-text-menu>
|
||||
`,
|
||||
],
|
||||
['nothing', () => nothing],
|
||||
]
|
||||
),
|
||||
].filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor elements: ConnectorElementModel[] = [];
|
||||
|
||||
@query('edgeless-color-picker-button.stroke-color')
|
||||
accessor strokeColorButton!: EdgelessColorPickerButton;
|
||||
}
|
||||
|
||||
export function renderConnectorButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
elements?: ConnectorElementModel[]
|
||||
) {
|
||||
if (!elements?.length) return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-change-connector-button
|
||||
.elements=${elements}
|
||||
.edgeless=${edgeless}
|
||||
>
|
||||
</edgeless-change-connector-button>
|
||||
`;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import type { EdgelessTextBlockModel } from '@blocksuite/affine-model';
|
||||
import { html, nothing } from 'lit';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
|
||||
export function renderChangeEdgelessTextButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
elements?: EdgelessTextBlockModel[]
|
||||
) {
|
||||
if (!elements?.length) return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-change-text-menu
|
||||
.elementType=${'edgeless-text'}
|
||||
.elements=${elements}
|
||||
.edgeless=${edgeless}
|
||||
></edgeless-change-text-menu>
|
||||
`;
|
||||
}
|
||||
+910
@@ -0,0 +1,910 @@
|
||||
import {} from '@blocksuite/affine-block-bookmark';
|
||||
import {
|
||||
EmbedLinkedDocBlockComponent,
|
||||
EmbedSyncedDocBlockComponent,
|
||||
getDocContentWithMaxLength,
|
||||
getEmbedCardIcons,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
reassociateConnectorsCommand,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { toggleEmbedCardEditModal } from '@blocksuite/affine-components/embed-card-modal';
|
||||
import {
|
||||
CaptionIcon,
|
||||
CopyIcon,
|
||||
EditIcon,
|
||||
ExpandFullSmallIcon,
|
||||
OpenIcon,
|
||||
PaletteIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import {
|
||||
notifyLinkedDocClearedAliases,
|
||||
notifyLinkedDocSwitchedToCard,
|
||||
notifyLinkedDocSwitchedToEmbed,
|
||||
} from '@blocksuite/affine-components/notification';
|
||||
import { isPeekable, peek } from '@blocksuite/affine-components/peek';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import {
|
||||
type MenuItem,
|
||||
renderToolbarSeparator,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
type AliasInfo,
|
||||
BookmarkStyles,
|
||||
type BuiltInEmbedModel,
|
||||
type EmbedCardStyle,
|
||||
isInternalEmbedModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
EmbedOptionProvider,
|
||||
type EmbedOptions,
|
||||
GenerateDocUrlProvider,
|
||||
type GenerateDocUrlService,
|
||||
type LinkEventType,
|
||||
OpenDocExtensionIdentifier,
|
||||
type OpenDocMode,
|
||||
type TelemetryEvent,
|
||||
TelemetryProvider,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getHostName, referenceToNode } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import {
|
||||
isBookmarkBlock,
|
||||
isEmbedGithubBlock,
|
||||
isEmbedHtmlBlock,
|
||||
isEmbedLinkedDocBlock,
|
||||
isEmbedSyncedDocBlock,
|
||||
} from '../../edgeless/utils/query.js';
|
||||
import type { BuiltInEmbedBlockComponent } from '../../utils/types';
|
||||
import { SmallArrowDownIcon } from './icons.js';
|
||||
|
||||
export class EdgelessChangeEmbedCardButton extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.affine-link-preview {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
width: 140px;
|
||||
padding: var(--1, 0px);
|
||||
border-radius: var(--1, 0px);
|
||||
opacity: var(--add, 1);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
color: var(--affine-link-color);
|
||||
font-feature-settings:
|
||||
'clig' off,
|
||||
'liga' off;
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-sm);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.affine-link-preview > span {
|
||||
display: inline-block;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
editor-icon-button.doc-title .label {
|
||||
max-width: 110px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
color: var(--affine-link-color);
|
||||
font-feature-settings:
|
||||
'clig' off,
|
||||
'liga' off;
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-sm);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
get crud() {
|
||||
return this.edgeless.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
private readonly _convertToCardView = () => {
|
||||
if (this._isCardView) {
|
||||
return;
|
||||
}
|
||||
|
||||
const block = this._blockComponent;
|
||||
if (block && 'convertToCard' in block) {
|
||||
block.convertToCard();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('url' in this.model)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, url, xywh, style, caption } = this.model;
|
||||
|
||||
let targetFlavour = 'affine:bookmark',
|
||||
targetStyle = style;
|
||||
|
||||
if (this._embedOptions && this._embedOptions.viewType === 'card') {
|
||||
const { flavour, styles } = this._embedOptions;
|
||||
targetFlavour = flavour;
|
||||
targetStyle = styles.includes(style) ? style : styles[0];
|
||||
} else {
|
||||
targetStyle = BookmarkStyles.includes(style) ? style : BookmarkStyles[0];
|
||||
}
|
||||
|
||||
const bound = Bound.deserialize(xywh);
|
||||
bound.w = EMBED_CARD_WIDTH[targetStyle];
|
||||
bound.h = EMBED_CARD_HEIGHT[targetStyle];
|
||||
|
||||
const newId = this.crud.addBlock(
|
||||
targetFlavour,
|
||||
{ url, xywh: bound.serialize(), style: targetStyle, caption },
|
||||
this.edgeless.surface.model
|
||||
);
|
||||
|
||||
this.std.command.exec(reassociateConnectorsCommand, {
|
||||
oldId: id,
|
||||
newId,
|
||||
});
|
||||
|
||||
this.edgeless.service.selection.set({
|
||||
editing: false,
|
||||
elements: [newId],
|
||||
});
|
||||
this._doc.deleteBlock(this.model);
|
||||
};
|
||||
|
||||
private readonly _convertToEmbedView = () => {
|
||||
if (this._isEmbedView) {
|
||||
return;
|
||||
}
|
||||
|
||||
const block = this._blockComponent;
|
||||
if (block && 'convertToEmbed' in block) {
|
||||
const referenceInfo = block.referenceInfo$.peek();
|
||||
|
||||
block.convertToEmbed();
|
||||
|
||||
if (referenceInfo.title || referenceInfo.description)
|
||||
notifyLinkedDocSwitchedToEmbed(this.std);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('url' in this.model)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._embedOptions) return;
|
||||
|
||||
const { flavour, styles } = this._embedOptions;
|
||||
|
||||
const { id, url, xywh, style } = this.model;
|
||||
|
||||
const targetStyle = styles.includes(style) ? style : styles[0];
|
||||
|
||||
const bound = Bound.deserialize(xywh);
|
||||
bound.w = EMBED_CARD_WIDTH[targetStyle];
|
||||
bound.h = EMBED_CARD_HEIGHT[targetStyle];
|
||||
|
||||
const newId = this.crud.addBlock(
|
||||
flavour,
|
||||
{
|
||||
url,
|
||||
xywh: bound.serialize(),
|
||||
style: targetStyle,
|
||||
},
|
||||
this.edgeless.surface.model
|
||||
);
|
||||
if (!newId) return;
|
||||
|
||||
this.std.command.exec(reassociateConnectorsCommand, {
|
||||
oldId: id,
|
||||
newId,
|
||||
});
|
||||
|
||||
this.edgeless.service.selection.set({
|
||||
editing: false,
|
||||
elements: [newId],
|
||||
});
|
||||
this._doc.deleteBlock(this.model);
|
||||
};
|
||||
|
||||
private readonly _copyUrl = () => {
|
||||
let url!: ReturnType<GenerateDocUrlService['generateDocUrl']>;
|
||||
|
||||
if ('url' in this.model) {
|
||||
url = this.model.url;
|
||||
} else if (isInternalEmbedModel(this.model)) {
|
||||
url = this.std
|
||||
.getOptional(GenerateDocUrlProvider)
|
||||
?.generateDocUrl(this.model.pageId, this.model.params);
|
||||
}
|
||||
|
||||
if (!url) return;
|
||||
|
||||
navigator.clipboard.writeText(url).catch(console.error);
|
||||
toast(this.std.host, 'Copied link to clipboard');
|
||||
this.edgeless.service.selection.clear();
|
||||
|
||||
track(this.std, this.model, this._viewType, 'CopiedLink', {
|
||||
control: 'copy link',
|
||||
});
|
||||
};
|
||||
|
||||
private _embedOptions: EmbedOptions | null = null;
|
||||
|
||||
private readonly _getScale = () => {
|
||||
if ('scale' in this.model) {
|
||||
return this.model.scale ?? 1;
|
||||
} else if (isEmbedHtmlBlock(this.model)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const bound = Bound.deserialize(this.model.xywh);
|
||||
return bound.h / EMBED_CARD_HEIGHT[this.model.style];
|
||||
};
|
||||
|
||||
private readonly _open = ({ openMode }: { openMode?: OpenDocMode } = {}) => {
|
||||
this._blockComponent?.open({ openMode });
|
||||
};
|
||||
|
||||
private readonly _openEditPopup = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isEmbedHtmlBlock(this.model)) return;
|
||||
|
||||
this.std.selection.clear();
|
||||
|
||||
const originalDocInfo = this._originalDocInfo;
|
||||
|
||||
toggleEmbedCardEditModal(
|
||||
this.std.host,
|
||||
this.model,
|
||||
this._viewType,
|
||||
originalDocInfo,
|
||||
(std, component) => {
|
||||
if (
|
||||
isEmbedLinkedDocBlock(this.model) &&
|
||||
component instanceof EmbedLinkedDocBlockComponent
|
||||
) {
|
||||
component.refreshData();
|
||||
|
||||
notifyLinkedDocClearedAliases(std);
|
||||
}
|
||||
},
|
||||
(std, component, props) => {
|
||||
if (
|
||||
isEmbedSyncedDocBlock(this.model) &&
|
||||
component instanceof EmbedSyncedDocBlockComponent
|
||||
) {
|
||||
component.convertToCard(props);
|
||||
|
||||
notifyLinkedDocSwitchedToCard(std);
|
||||
} else {
|
||||
this.model.doc.updateBlock(this.model, props);
|
||||
component.requestUpdate();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
track(this.std, this.model, this._viewType, 'OpenedAliasPopup', {
|
||||
control: 'edit',
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _peek = () => {
|
||||
if (!this._blockComponent) return;
|
||||
peek(this._blockComponent);
|
||||
};
|
||||
|
||||
private readonly _setCardStyle = (style: EmbedCardStyle) => {
|
||||
const bounds = Bound.deserialize(this.model.xywh);
|
||||
bounds.w = EMBED_CARD_WIDTH[style];
|
||||
bounds.h = EMBED_CARD_HEIGHT[style];
|
||||
const xywh = bounds.serialize();
|
||||
this.model.doc.updateBlock(this.model, { style, xywh });
|
||||
|
||||
track(this.std, this.model, this._viewType, 'SelectedCardStyle', {
|
||||
control: 'select card style',
|
||||
type: style,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _setEmbedScale = (scale: number) => {
|
||||
if (isEmbedHtmlBlock(this.model)) return;
|
||||
|
||||
const bound = Bound.deserialize(this.model.xywh);
|
||||
if ('scale' in this.model) {
|
||||
const oldScale = this.model.scale ?? 1;
|
||||
const ratio = scale / oldScale;
|
||||
bound.w *= ratio;
|
||||
bound.h *= ratio;
|
||||
const xywh = bound.serialize();
|
||||
this.model.doc.updateBlock(this.model, { scale, xywh });
|
||||
} else {
|
||||
bound.h = EMBED_CARD_HEIGHT[this.model.style] * scale;
|
||||
bound.w = EMBED_CARD_WIDTH[this.model.style] * scale;
|
||||
const xywh = bound.serialize();
|
||||
this.model.doc.updateBlock(this.model, { xywh });
|
||||
}
|
||||
this._embedScale = scale;
|
||||
|
||||
track(this.std, this.model, this._viewType, 'SelectedCardScale', {
|
||||
control: 'select card scale',
|
||||
type: `${scale}`,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _toggleCardScaleSelector = (e: Event) => {
|
||||
const opened = (e as CustomEvent<boolean>).detail;
|
||||
if (!opened) return;
|
||||
|
||||
track(this.std, this.model, this._viewType, 'OpenedCardScaleSelector', {
|
||||
control: 'switch card scale',
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _toggleCardStyleSelector = (e: Event) => {
|
||||
const opened = (e as CustomEvent<boolean>).detail;
|
||||
if (!opened) return;
|
||||
|
||||
track(this.std, this.model, this._viewType, 'OpenedCardStyleSelector', {
|
||||
control: 'switch card style',
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _toggleViewSelector = (e: Event) => {
|
||||
const opened = (e as CustomEvent<boolean>).detail;
|
||||
if (!opened) return;
|
||||
|
||||
track(this.std, this.model, this._viewType, 'OpenedViewSelector', {
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _trackViewSelected = (type: string) => {
|
||||
track(this.std, this.model, this._viewType, 'SelectedView', {
|
||||
control: 'select view',
|
||||
type: `${type} view`,
|
||||
});
|
||||
};
|
||||
|
||||
private get _blockComponent() {
|
||||
const blockSelection =
|
||||
this.edgeless.service.selection.surfaceSelections.filter(sel =>
|
||||
sel.elements.includes(this.model.id)
|
||||
);
|
||||
if (blockSelection.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blockComponent = this.std.view.getBlock(
|
||||
blockSelection[0].blockId
|
||||
) as BuiltInEmbedBlockComponent | null;
|
||||
|
||||
if (!blockComponent) return;
|
||||
|
||||
return blockComponent;
|
||||
}
|
||||
|
||||
private get _canConvertToEmbedView() {
|
||||
const block = this._blockComponent;
|
||||
|
||||
return (
|
||||
(block && 'convertToEmbed' in block) ||
|
||||
this._embedOptions?.viewType === 'embed'
|
||||
);
|
||||
}
|
||||
|
||||
private get _canShowCardStylePanel() {
|
||||
return (
|
||||
isBookmarkBlock(this.model) ||
|
||||
isEmbedGithubBlock(this.model) ||
|
||||
isEmbedLinkedDocBlock(this.model)
|
||||
);
|
||||
}
|
||||
|
||||
private get _canShowFullScreenButton() {
|
||||
return isEmbedHtmlBlock(this.model);
|
||||
}
|
||||
|
||||
private get _canShowUrlOptions() {
|
||||
return (
|
||||
'url' in this.model &&
|
||||
(isBookmarkBlock(this.model) ||
|
||||
isEmbedGithubBlock(this.model) ||
|
||||
isEmbedLinkedDocBlock(this.model))
|
||||
);
|
||||
}
|
||||
|
||||
private get _doc() {
|
||||
return this.model.doc;
|
||||
}
|
||||
|
||||
private get _embedViewButtonDisabled() {
|
||||
if (this._doc.readonly) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
isEmbedLinkedDocBlock(this.model) &&
|
||||
(referenceToNode(this.model) ||
|
||||
!!this._blockComponent?.closest('affine-embed-synced-doc-block') ||
|
||||
this.model.pageId === this._doc.id)
|
||||
);
|
||||
}
|
||||
|
||||
private get _getCardStyleOptions(): {
|
||||
style: EmbedCardStyle;
|
||||
Icon: TemplateResult<1>;
|
||||
tooltip: string;
|
||||
}[] {
|
||||
const theme = this.std.get(ThemeProvider).theme;
|
||||
const {
|
||||
EmbedCardHorizontalIcon,
|
||||
EmbedCardListIcon,
|
||||
EmbedCardVerticalIcon,
|
||||
EmbedCardCubeIcon,
|
||||
} = getEmbedCardIcons(theme);
|
||||
return [
|
||||
{
|
||||
style: 'horizontal',
|
||||
Icon: EmbedCardHorizontalIcon,
|
||||
tooltip: 'Large horizontal style',
|
||||
},
|
||||
{
|
||||
style: 'list',
|
||||
Icon: EmbedCardListIcon,
|
||||
tooltip: 'Small horizontal style',
|
||||
},
|
||||
{
|
||||
style: 'vertical',
|
||||
Icon: EmbedCardVerticalIcon,
|
||||
tooltip: 'Large vertical style',
|
||||
},
|
||||
{
|
||||
style: 'cube',
|
||||
Icon: EmbedCardCubeIcon,
|
||||
tooltip: 'Small vertical style',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private get _isCardView() {
|
||||
if (isBookmarkBlock(this.model) || isEmbedLinkedDocBlock(this.model)) {
|
||||
return true;
|
||||
}
|
||||
return this._embedOptions?.viewType === 'card';
|
||||
}
|
||||
|
||||
private get _isEmbedView() {
|
||||
return (
|
||||
!isBookmarkBlock(this.model) &&
|
||||
(isEmbedSyncedDocBlock(this.model) ||
|
||||
this._embedOptions?.viewType === 'embed')
|
||||
);
|
||||
}
|
||||
|
||||
get _originalDocInfo(): AliasInfo | undefined {
|
||||
const model = this.model;
|
||||
const doc = isInternalEmbedModel(model)
|
||||
? this.std.workspace.getDoc(model.pageId)
|
||||
: null;
|
||||
|
||||
if (doc) {
|
||||
const title = doc.meta?.title;
|
||||
const description = isEmbedLinkedDocBlock(model)
|
||||
? getDocContentWithMaxLength(doc)
|
||||
: undefined;
|
||||
return { title, description };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get _originalDocTitle() {
|
||||
const model = this.model;
|
||||
const doc = isInternalEmbedModel(model)
|
||||
? this.std.workspace.getDoc(model.pageId)
|
||||
: null;
|
||||
|
||||
return doc?.meta?.title || 'Untitled';
|
||||
}
|
||||
|
||||
private get _viewType(): 'inline' | 'embed' | 'card' {
|
||||
if (this._isCardView) {
|
||||
return 'card';
|
||||
}
|
||||
|
||||
if (this._isEmbedView) {
|
||||
return 'embed';
|
||||
}
|
||||
|
||||
// unreachable
|
||||
return 'inline';
|
||||
}
|
||||
|
||||
private get std() {
|
||||
return this.edgeless.std;
|
||||
}
|
||||
|
||||
private _openMenuButton() {
|
||||
const openDocConfig = this.std.get(OpenDocExtensionIdentifier);
|
||||
const buttons: MenuItem[] = openDocConfig.items
|
||||
.map(item => {
|
||||
if (
|
||||
item.type === 'open-in-center-peek' &&
|
||||
this._blockComponent &&
|
||||
!isPeekable(this._blockComponent)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
isEmbedLinkedDocBlock(this.model) ||
|
||||
isEmbedSyncedDocBlock(this.model)
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: item.label,
|
||||
type: item.type,
|
||||
icon: item.icon,
|
||||
disabled:
|
||||
this.model.pageId === this._doc.id &&
|
||||
item.type === 'open-in-active-view',
|
||||
action: () => {
|
||||
if (item.type === 'open-in-center-peek') {
|
||||
this._peek();
|
||||
} else {
|
||||
this._open({ openMode: item.type });
|
||||
}
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(item => item !== null);
|
||||
|
||||
// todo: abstract this?
|
||||
if (this._canShowFullScreenButton) {
|
||||
buttons.push({
|
||||
type: 'open-this-doc',
|
||||
label: 'Open this doc',
|
||||
icon: ExpandFullSmallIcon,
|
||||
action: this._open,
|
||||
});
|
||||
}
|
||||
|
||||
if (buttons.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Open"
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
>
|
||||
${OpenIcon}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
buttons,
|
||||
button => button.label,
|
||||
({ label, icon, action, disabled }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${ifDefined(label)}
|
||||
?disabled=${disabled}
|
||||
@click=${action}
|
||||
>
|
||||
${icon}<span class="label">${label}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _showCaption() {
|
||||
this._blockComponent?.captionEditor?.show();
|
||||
|
||||
track(this.std, this.model, this._viewType, 'OpenedCaptionEditor', {
|
||||
control: 'add caption',
|
||||
});
|
||||
}
|
||||
|
||||
private _viewSelector() {
|
||||
if (this._canConvertToEmbedView || this._isEmbedView) {
|
||||
const buttons = [
|
||||
{
|
||||
type: 'card',
|
||||
label: 'Card view',
|
||||
action: () => this._convertToCardView(),
|
||||
disabled: this.model.doc.readonly,
|
||||
},
|
||||
{
|
||||
type: 'embed',
|
||||
label: 'Embed view',
|
||||
action: () => this._convertToEmbedView(),
|
||||
disabled: this.model.doc.readonly || this._embedViewButtonDisabled,
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Switch view"
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
.iconContainerWidth=${'110px'}
|
||||
>
|
||||
<div class="label">
|
||||
<span style="text-transform: capitalize"
|
||||
>${this._viewType}</span
|
||||
>
|
||||
view
|
||||
</div>
|
||||
${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
@toggle=${this._toggleViewSelector}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
buttons,
|
||||
button => button.type,
|
||||
({ type, label, action, disabled }) => html`
|
||||
<editor-menu-action
|
||||
data-testid=${`link-to-${type}`}
|
||||
aria-label=${ifDefined(label)}
|
||||
?data-selected=${this._viewType === type}
|
||||
?disabled=${disabled || this._viewType === type}
|
||||
@click=${() => {
|
||||
action();
|
||||
this._trackViewSelected(type);
|
||||
}}
|
||||
>
|
||||
${label}
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._embedScale = this._getScale();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const model = this.model;
|
||||
const isHtmlBlockModel = isEmbedHtmlBlock(model);
|
||||
|
||||
if ('url' in this.model) {
|
||||
this._embedOptions = this.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(this.model.url);
|
||||
}
|
||||
|
||||
const buttons = [
|
||||
this._openMenuButton(),
|
||||
|
||||
this._canShowUrlOptions && 'url' in model
|
||||
? html`
|
||||
<a
|
||||
class="affine-link-preview"
|
||||
href=${model.url}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span>${getHostName(model.url)}</span>
|
||||
</a>
|
||||
`
|
||||
: nothing,
|
||||
|
||||
// internal embed model
|
||||
isEmbedLinkedDocBlock(model) && model.title
|
||||
? html`
|
||||
<editor-icon-button
|
||||
class="doc-title"
|
||||
aria-label="Doc title"
|
||||
.hover=${false}
|
||||
.labelHeight=${'20px'}
|
||||
.tooltip=${this._originalDocTitle}
|
||||
@click=${this._open}
|
||||
>
|
||||
<span class="label">${this._originalDocTitle}</span>
|
||||
</editor-icon-button>
|
||||
`
|
||||
: nothing,
|
||||
|
||||
isHtmlBlockModel
|
||||
? nothing
|
||||
: html`
|
||||
<editor-icon-button
|
||||
aria-label="Click link"
|
||||
.tooltip=${'Click link'}
|
||||
class="change-embed-card-button copy"
|
||||
?disabled=${this._doc.readonly}
|
||||
@click=${this._copyUrl}
|
||||
>
|
||||
${CopyIcon}
|
||||
</editor-icon-button>
|
||||
|
||||
<editor-icon-button
|
||||
aria-label="Edit"
|
||||
.tooltip=${'Edit'}
|
||||
class="change-embed-card-button edit"
|
||||
?disabled=${this._doc.readonly}
|
||||
@click=${this._openEditPopup}
|
||||
>
|
||||
${EditIcon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
|
||||
this._viewSelector(),
|
||||
|
||||
'style' in model && this._canShowCardStylePanel
|
||||
? html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Card style"
|
||||
.tooltip=${'Card style'}
|
||||
>
|
||||
${PaletteIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
@toggle=${this._toggleCardStyleSelector}
|
||||
>
|
||||
<card-style-panel
|
||||
.value=${model.style}
|
||||
.options=${this._getCardStyleOptions}
|
||||
.onSelect=${this._setCardStyle}
|
||||
>
|
||||
</card-style-panel>
|
||||
</editor-menu-button>
|
||||
`
|
||||
: nothing,
|
||||
|
||||
'caption' in model
|
||||
? html`
|
||||
<editor-icon-button
|
||||
aria-label="Add caption"
|
||||
.tooltip=${'Add caption'}
|
||||
class="change-embed-card-button caption"
|
||||
?disabled=${this._doc.readonly}
|
||||
@click=${this._showCaption}
|
||||
>
|
||||
${CaptionIcon}
|
||||
</editor-icon-button>
|
||||
`
|
||||
: nothing,
|
||||
|
||||
this.quickConnectButton,
|
||||
|
||||
isHtmlBlockModel
|
||||
? nothing
|
||||
: html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Scale"
|
||||
.tooltip=${'Scale'}
|
||||
.justify=${'space-between'}
|
||||
.iconContainerWidth=${'65px'}
|
||||
.labelHeight=${'20px'}
|
||||
>
|
||||
<span class="label">
|
||||
${Math.round(this._embedScale * 100) + '%'}
|
||||
</span>
|
||||
${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
@toggle=${this._toggleCardScaleSelector}
|
||||
>
|
||||
<edgeless-scale-panel
|
||||
class="embed-scale-popper"
|
||||
.scale=${Math.round(this._embedScale * 100)}
|
||||
.onSelect=${this._setEmbedScale}
|
||||
></edgeless-scale-panel>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
];
|
||||
|
||||
return join(
|
||||
buttons.filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
);
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _embedScale = 1;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor model!: BuiltInEmbedModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor quickConnectButton!: TemplateResult<1> | typeof nothing;
|
||||
}
|
||||
|
||||
export function renderEmbedButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
models?: EdgelessChangeEmbedCardButton['model'][],
|
||||
quickConnectButton?: TemplateResult<1>[]
|
||||
) {
|
||||
if (models?.length !== 1) return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-change-embed-card-button
|
||||
.model=${models[0]}
|
||||
.edgeless=${edgeless}
|
||||
.quickConnectButton=${quickConnectButton?.pop() ?? nothing}
|
||||
></edgeless-change-embed-card-button>
|
||||
`;
|
||||
}
|
||||
|
||||
function track(
|
||||
std: BlockStdScope,
|
||||
model: BuiltInEmbedModel,
|
||||
viewType: string,
|
||||
event: LinkEventType,
|
||||
props: Partial<TelemetryEvent>
|
||||
) {
|
||||
std.getOptional(TelemetryProvider)?.track(event, {
|
||||
segment: 'toolbar',
|
||||
page: 'whiteboard editor',
|
||||
module: 'element toolbar',
|
||||
type: `${viewType} view`,
|
||||
category: isInternalEmbedModel(model) ? 'linked doc' : 'link',
|
||||
...props,
|
||||
});
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
import type { EdgelessFrameManager } from '@blocksuite/affine-block-frame';
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import type {
|
||||
EdgelessColorPickerButton,
|
||||
PickColorEvent,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import {
|
||||
packColor,
|
||||
packColorsWithColorScheme,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
type ColorScheme,
|
||||
DEFAULT_NOTE_HEIGHT,
|
||||
type FrameBlockModel,
|
||||
NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
resolveColor,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import { GfxExtensionIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import { deserializeXYWH, serializeXYWH } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { EditIcon, PageIcon, UngroupIcon } from '@blocksuite/icons/lit';
|
||||
import { html, LitElement, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
import countBy from 'lodash-es/countBy';
|
||||
import maxBy from 'lodash-es/maxBy';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import { mountFrameTitleEditor } from '../../edgeless/utils/text.js';
|
||||
|
||||
function getMostCommonColor(
|
||||
elements: FrameBlockModel[],
|
||||
colorScheme: ColorScheme
|
||||
): string {
|
||||
const colors = countBy(elements, (ele: FrameBlockModel) =>
|
||||
resolveColor(ele.background, colorScheme)
|
||||
);
|
||||
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
|
||||
return max ? (max[0] as string) : 'transparent';
|
||||
}
|
||||
|
||||
export class EdgelessChangeFrameButton extends WithDisposable(LitElement) {
|
||||
get crud() {
|
||||
return this.edgeless.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
private readonly _setFrameBackground = (e: ColorEvent) => {
|
||||
const background = e.detail.value;
|
||||
this.frames.forEach(frame => {
|
||||
this.crud.updateElement(frame.id, { background });
|
||||
});
|
||||
};
|
||||
|
||||
pickColor = (e: PickColorEvent) => {
|
||||
const field = 'background';
|
||||
|
||||
if (e.type === 'pick') {
|
||||
const color = e.detail.value;
|
||||
this.frames.forEach(ele => {
|
||||
const props = packColor(field, color);
|
||||
this.crud.updateElement(ele.id, props);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.frames.forEach(ele =>
|
||||
ele[e.type === 'start' ? 'stash' : 'pop'](field)
|
||||
);
|
||||
};
|
||||
|
||||
get service() {
|
||||
return this.edgeless.service;
|
||||
}
|
||||
|
||||
private _insertIntoPage() {
|
||||
if (!this.edgeless.doc.root) return;
|
||||
|
||||
const rootModel = this.edgeless.doc.root;
|
||||
const notes = rootModel.children.filter(
|
||||
model =>
|
||||
matchModels(model, [NoteBlockModel]) &&
|
||||
model.displayMode !== NoteDisplayMode.EdgelessOnly
|
||||
);
|
||||
const lastNote = notes[notes.length - 1];
|
||||
const referenceFrame = this.frames[0];
|
||||
|
||||
let targetParent = lastNote?.id;
|
||||
|
||||
if (!lastNote) {
|
||||
const targetXYWH = deserializeXYWH(referenceFrame.xywh);
|
||||
|
||||
targetXYWH[1] = targetXYWH[1] + targetXYWH[3];
|
||||
targetXYWH[3] = DEFAULT_NOTE_HEIGHT;
|
||||
|
||||
const newAddedNote = this.edgeless.doc.addBlock(
|
||||
'affine:note',
|
||||
{
|
||||
xywh: serializeXYWH(...targetXYWH),
|
||||
},
|
||||
rootModel.id
|
||||
);
|
||||
|
||||
targetParent = newAddedNote;
|
||||
}
|
||||
|
||||
this.edgeless.doc.addBlock(
|
||||
'affine:surface-ref',
|
||||
{
|
||||
reference: this.frames[0].id,
|
||||
refFlavour: 'affine:frame',
|
||||
},
|
||||
targetParent
|
||||
);
|
||||
|
||||
toast(this.edgeless.host, 'Frame has been inserted into doc');
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
const { frames } = this;
|
||||
const len = frames.length;
|
||||
const onlyOne = len === 1;
|
||||
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
|
||||
const background = getMostCommonColor(frames, colorScheme);
|
||||
|
||||
return join(
|
||||
[
|
||||
onlyOne
|
||||
? html`
|
||||
<editor-icon-button
|
||||
aria-label=${'Insert into Page'}
|
||||
.tooltip=${'Insert into Page'}
|
||||
.iconSize=${'20px'}
|
||||
.labelHeight=${'20px'}
|
||||
@click=${this._insertIntoPage}
|
||||
>
|
||||
${PageIcon()}
|
||||
<span class="label">Insert into Page</span>
|
||||
</editor-icon-button>
|
||||
`
|
||||
: nothing,
|
||||
|
||||
onlyOne
|
||||
? html`
|
||||
<editor-icon-button
|
||||
aria-label="Rename"
|
||||
.tooltip=${'Rename'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() =>
|
||||
mountFrameTitleEditor(this.frames[0], this.edgeless)}
|
||||
>
|
||||
${EditIcon()}
|
||||
</editor-icon-button>
|
||||
`
|
||||
: nothing,
|
||||
|
||||
html`
|
||||
<editor-icon-button
|
||||
aria-label="Ungroup"
|
||||
.tooltip=${'Ungroup'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() => {
|
||||
this.edgeless.doc.captureSync();
|
||||
const frameMgr = this.edgeless.std.get(
|
||||
GfxExtensionIdentifier('frame-manager')
|
||||
) as EdgelessFrameManager;
|
||||
frames.forEach(frame =>
|
||||
frameMgr.removeAllChildrenFromFrame(frame)
|
||||
);
|
||||
frames.forEach(frame => {
|
||||
this.edgeless.service.removeElement(frame);
|
||||
});
|
||||
this.edgeless.service.selection.clear();
|
||||
}}
|
||||
>
|
||||
${UngroupIcon()}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
|
||||
when(
|
||||
this.edgeless.doc
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_color_picker'),
|
||||
() => {
|
||||
const { type, colors } = packColorsWithColorScheme(
|
||||
colorScheme,
|
||||
background,
|
||||
this.frames[0].background
|
||||
);
|
||||
|
||||
return html`
|
||||
<edgeless-color-picker-button
|
||||
class="background"
|
||||
.label="${'Background'}"
|
||||
.pick=${this.pickColor}
|
||||
.color=${background}
|
||||
.colors=${colors}
|
||||
.colorType=${type}
|
||||
.theme=${colorScheme}
|
||||
>
|
||||
</edgeless-color-picker-button>
|
||||
`;
|
||||
},
|
||||
() => html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Background"
|
||||
.tooltip=${'Background'}
|
||||
>
|
||||
<edgeless-color-button
|
||||
.color=${background}
|
||||
></edgeless-color-button>
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-color-panel
|
||||
.value=${background}
|
||||
.theme=${colorScheme}
|
||||
@select=${this._setFrameBackground}
|
||||
>
|
||||
</edgeless-color-panel>
|
||||
</editor-menu-button>
|
||||
`
|
||||
),
|
||||
].filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
);
|
||||
}
|
||||
|
||||
@query('edgeless-color-picker-button.background')
|
||||
accessor backgroundButton!: EdgelessColorPickerButton;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor frames: FrameBlockModel[] = [];
|
||||
}
|
||||
|
||||
export function renderFrameButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
frames?: FrameBlockModel[]
|
||||
) {
|
||||
if (!frames?.length) return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-change-frame-button
|
||||
.edgeless=${edgeless}
|
||||
.frames=${frames}
|
||||
></edgeless-change-frame-button>
|
||||
`;
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
DEFAULT_NOTE_HEIGHT,
|
||||
NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import { deserializeXYWH, serializeXYWH } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { EditIcon, PageIcon, UngroupIcon } from '@blocksuite/icons/lit';
|
||||
import { html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import { mountGroupTitleEditor } from '../../edgeless/utils/text.js';
|
||||
|
||||
export class EdgelessChangeGroupButton extends WithDisposable(LitElement) {
|
||||
private _insertIntoPage() {
|
||||
if (!this.edgeless.doc.root) return;
|
||||
|
||||
const rootModel = this.edgeless.doc.root;
|
||||
const notes = rootModel.children.filter(
|
||||
model =>
|
||||
matchModels(model, [NoteBlockModel]) &&
|
||||
model.displayMode !== NoteDisplayMode.EdgelessOnly
|
||||
);
|
||||
const lastNote = notes[notes.length - 1];
|
||||
const referenceGroup = this.groups[0];
|
||||
|
||||
let targetParent = lastNote?.id;
|
||||
|
||||
if (!lastNote) {
|
||||
const targetXYWH = deserializeXYWH(referenceGroup.xywh);
|
||||
|
||||
targetXYWH[1] = targetXYWH[1] + targetXYWH[3];
|
||||
targetXYWH[3] = DEFAULT_NOTE_HEIGHT;
|
||||
|
||||
const newAddedNote = this.edgeless.doc.addBlock(
|
||||
'affine:note',
|
||||
{
|
||||
xywh: serializeXYWH(...targetXYWH),
|
||||
},
|
||||
rootModel.id
|
||||
);
|
||||
|
||||
targetParent = newAddedNote;
|
||||
}
|
||||
|
||||
this.edgeless.doc.addBlock(
|
||||
'affine:surface-ref',
|
||||
{
|
||||
reference: this.groups[0].id,
|
||||
refFlavour: 'group',
|
||||
},
|
||||
targetParent
|
||||
);
|
||||
|
||||
toast(this.edgeless.host, 'Group has been inserted into page');
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
const { groups } = this;
|
||||
const onlyOne = groups.length === 1;
|
||||
|
||||
return join(
|
||||
[
|
||||
onlyOne
|
||||
? html`
|
||||
<editor-icon-button
|
||||
aria-label="Insert into Page"
|
||||
.tooltip=${'Insert into Page'}
|
||||
.iconSize=${'20px'}
|
||||
.labelHeight=${'20px'}
|
||||
@click=${this._insertIntoPage}
|
||||
>
|
||||
${PageIcon()}
|
||||
<span class="label">Insert into Page</span>
|
||||
</editor-icon-button>
|
||||
`
|
||||
: nothing,
|
||||
|
||||
onlyOne
|
||||
? html`
|
||||
<editor-icon-button
|
||||
aria-label="Rename"
|
||||
.tooltip=${'Rename'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() => mountGroupTitleEditor(groups[0], this.edgeless)}
|
||||
>
|
||||
${EditIcon()}
|
||||
</editor-icon-button>
|
||||
`
|
||||
: nothing,
|
||||
|
||||
html`
|
||||
<editor-icon-button
|
||||
aria-label="Ungroup"
|
||||
.tooltip=${'Ungroup'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() =>
|
||||
groups.forEach(group => this.edgeless.service.ungroup(group))}
|
||||
>
|
||||
${UngroupIcon()}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
].filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor groups!: GroupElementModel[];
|
||||
}
|
||||
|
||||
export function renderGroupButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
groups?: GroupElementModel[]
|
||||
) {
|
||||
if (!groups?.length) return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-change-group-button .edgeless=${edgeless} .groups=${groups}>
|
||||
</edgeless-change-group-button>
|
||||
`;
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
downloadImageBlob,
|
||||
type ImageBlockComponent,
|
||||
} from '@blocksuite/affine-block-image';
|
||||
import { CaptionIcon, DownloadIcon } from '@blocksuite/affine-components/icons';
|
||||
import type { ImageBlockModel } from '@blocksuite/affine-model';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
|
||||
export class EdgelessChangeImageButton extends WithDisposable(LitElement) {
|
||||
private readonly _download = () => {
|
||||
if (!this._blockComponent) return;
|
||||
downloadImageBlob(this._blockComponent).catch(console.error);
|
||||
};
|
||||
|
||||
private readonly _showCaption = () => {
|
||||
this._blockComponent?.captionEditor?.show();
|
||||
};
|
||||
|
||||
private get _blockComponent() {
|
||||
const blockSelection =
|
||||
this.edgeless.service.selection.surfaceSelections.filter(sel =>
|
||||
sel.elements.includes(this.model.id)
|
||||
);
|
||||
if (blockSelection.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const block = this.edgeless.std.view.getBlock(
|
||||
blockSelection[0].blockId
|
||||
) as ImageBlockComponent | null;
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
private get _doc() {
|
||||
return this.model.doc;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<editor-icon-button
|
||||
aria-label="Download"
|
||||
.tooltip=${'Download'}
|
||||
?disabled=${this._doc.readonly}
|
||||
@click=${this._download}
|
||||
>
|
||||
${DownloadIcon}
|
||||
</editor-icon-button>
|
||||
|
||||
<editor-toolbar-separator></editor-toolbar-separator>
|
||||
|
||||
<editor-icon-button
|
||||
aria-label="Add caption"
|
||||
.tooltip=${'Add caption'}
|
||||
class="change-image-button caption"
|
||||
?disabled=${this._doc.readonly}
|
||||
@click=${this._showCaption}
|
||||
>
|
||||
${CaptionIcon}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor model!: ImageBlockModel;
|
||||
}
|
||||
|
||||
export function renderChangeImageButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
images?: ImageBlockModel[]
|
||||
) {
|
||||
if (images?.length !== 1) return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-change-image-button
|
||||
.model=${images[0]}
|
||||
.edgeless=${edgeless}
|
||||
></edgeless-change-image-button>
|
||||
`;
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
MindmapStyleFour,
|
||||
MindmapStyleOne,
|
||||
MindmapStyleThree,
|
||||
MindmapStyleTwo,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
|
||||
import type {
|
||||
MindmapElementModel,
|
||||
ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { LayoutType, MindmapStyle } from '@blocksuite/affine-model';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { RadiantIcon, RightLayoutIcon, StyleIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import countBy from 'lodash-es/countBy';
|
||||
import maxBy from 'lodash-es/maxBy';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import { SmallArrowDownIcon } from './icons.js';
|
||||
|
||||
const iconSize = { width: '20', height: '20' };
|
||||
|
||||
const MINDMAP_STYLE_LIST = [
|
||||
{
|
||||
value: MindmapStyle.ONE,
|
||||
icon: MindmapStyleOne,
|
||||
},
|
||||
{
|
||||
value: MindmapStyle.FOUR,
|
||||
icon: MindmapStyleFour,
|
||||
},
|
||||
{
|
||||
value: MindmapStyle.THREE,
|
||||
icon: MindmapStyleThree,
|
||||
},
|
||||
{
|
||||
value: MindmapStyle.TWO,
|
||||
icon: MindmapStyleTwo,
|
||||
},
|
||||
];
|
||||
|
||||
interface LayoutItem {
|
||||
name: string;
|
||||
value: LayoutType;
|
||||
icon: TemplateResult<1>;
|
||||
}
|
||||
|
||||
const MINDMAP_LAYOUT_LIST: LayoutItem[] = [
|
||||
{
|
||||
name: 'Left',
|
||||
value: LayoutType.LEFT,
|
||||
icon: RightLayoutIcon({
|
||||
...iconSize,
|
||||
style: 'transform: rotate(0.5turn); transform-origin: center;',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Radial',
|
||||
value: LayoutType.BALANCE,
|
||||
icon: RadiantIcon(iconSize),
|
||||
},
|
||||
{
|
||||
name: 'Right',
|
||||
value: LayoutType.RIGHT,
|
||||
icon: RightLayoutIcon(iconSize),
|
||||
},
|
||||
] as const;
|
||||
|
||||
export class EdgelessChangeMindmapStylePanel extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
}
|
||||
|
||||
.style-item {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.style-item > svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.style-item.active,
|
||||
.style-item:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return repeat(
|
||||
MINDMAP_STYLE_LIST,
|
||||
item => item.value,
|
||||
({ value, icon }) => html`
|
||||
<div
|
||||
role="button"
|
||||
class="style-item ${value === this.mindmapStyle ? 'active' : ''}"
|
||||
@click=${() => this.onSelect(value)}
|
||||
>
|
||||
${icon}
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor mindmapStyle!: MindmapStyle | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSelect!: (style: MindmapStyle) => void;
|
||||
}
|
||||
|
||||
export class EdgelessChangeMindmapLayoutPanel extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return repeat(
|
||||
MINDMAP_LAYOUT_LIST,
|
||||
item => item.value,
|
||||
({ name, value, icon }) => html`
|
||||
<editor-icon-button
|
||||
aria-label=${name}
|
||||
.tooltip=${name}
|
||||
.tipPosition=${'top'}
|
||||
.active=${this.mindmapLayout === value}
|
||||
.activeMode=${'background'}
|
||||
@click=${() => this.onSelect(value)}
|
||||
>
|
||||
${icon}
|
||||
</editor-icon-button>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor mindmapLayout!: LayoutType | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSelect!: (style: LayoutType) => void;
|
||||
}
|
||||
|
||||
export class EdgelessChangeMindmapButton extends WithDisposable(LitElement) {
|
||||
private readonly _updateLayoutType = (layoutType: LayoutType) => {
|
||||
this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', {
|
||||
layoutType,
|
||||
});
|
||||
this.elements.forEach(element => {
|
||||
element.layoutType = layoutType;
|
||||
element.layout();
|
||||
});
|
||||
this.layoutType = layoutType;
|
||||
};
|
||||
|
||||
private readonly _updateStyle = (style: MindmapStyle) => {
|
||||
this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', { style });
|
||||
this._mindmaps.forEach(element => (element.style = style));
|
||||
};
|
||||
|
||||
private get _mindmaps() {
|
||||
const mindmaps = new Set<MindmapElementModel>();
|
||||
|
||||
return this.elements.reduce((_, el) => {
|
||||
mindmaps.add(el);
|
||||
|
||||
return mindmaps;
|
||||
}, mindmaps);
|
||||
}
|
||||
|
||||
get layout() {
|
||||
const layoutType = this.layoutType ?? this._getCommonLayoutType();
|
||||
return MINDMAP_LAYOUT_LIST.find(item => item.value === layoutType)!;
|
||||
}
|
||||
|
||||
private _getCommonLayoutType() {
|
||||
const values = countBy(this.elements, element => element.layoutType);
|
||||
const max = maxBy(Object.entries(values), ([_k, count]) => count);
|
||||
return max ? (Number(max[0]) as LayoutType) : LayoutType.BALANCE;
|
||||
}
|
||||
|
||||
private _getCommonStyle() {
|
||||
const values = countBy(this.elements, element => element.style);
|
||||
const max = maxBy(Object.entries(values), ([_k, count]) => count);
|
||||
return max ? (Number(max[0]) as MindmapStyle) : MindmapStyle.ONE;
|
||||
}
|
||||
|
||||
private _isSubnode() {
|
||||
return (
|
||||
this.nodes.length === 1 &&
|
||||
(this.nodes[0].group as MindmapElementModel).tree.element !==
|
||||
this.nodes[0]
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return join(
|
||||
[
|
||||
html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="Style" .tooltip=${'Style'}>
|
||||
${StyleIcon(iconSize)}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-change-mindmap-style-panel
|
||||
.mindmapStyle=${this._getCommonStyle()}
|
||||
.onSelect=${this._updateStyle}
|
||||
>
|
||||
</edgeless-change-mindmap-style-panel>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
|
||||
this._isSubnode()
|
||||
? nothing
|
||||
: html`
|
||||
<editor-menu-button
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="Layout" .tooltip=${'Layout'}>
|
||||
${this.layout.icon}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-change-mindmap-layout-panel
|
||||
.mindmapLayout=${this.layout.value}
|
||||
.onSelect=${this._updateLayoutType}
|
||||
>
|
||||
</edgeless-change-mindmap-layout-panel>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
].filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor elements!: MindmapElementModel[];
|
||||
|
||||
@state()
|
||||
accessor layoutType!: LayoutType;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor nodes!: ShapeElementModel[];
|
||||
}
|
||||
|
||||
export function renderMindmapButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
elements?: (ShapeElementModel | MindmapElementModel)[]
|
||||
) {
|
||||
if (!elements?.length) return nothing;
|
||||
|
||||
const mindmaps: MindmapElementModel[] = [];
|
||||
|
||||
elements.forEach(e => {
|
||||
if (e.type === 'mindmap') {
|
||||
mindmaps.push(e as MindmapElementModel);
|
||||
}
|
||||
|
||||
const group = edgeless.service.surface.getGroup(e.id);
|
||||
|
||||
if (group && 'type' in group && group.type === 'mindmap') {
|
||||
mindmaps.push(group as MindmapElementModel);
|
||||
}
|
||||
});
|
||||
|
||||
if (mindmaps.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<edgeless-change-mindmap-button
|
||||
.elements=${mindmaps}
|
||||
.nodes=${elements.filter(e => e.type === 'shape')}
|
||||
.edgeless=${edgeless}
|
||||
>
|
||||
</edgeless-change-mindmap-button>
|
||||
`;
|
||||
}
|
||||
+617
@@ -0,0 +1,617 @@
|
||||
import {
|
||||
changeNoteDisplayMode,
|
||||
NoteConfigExtension,
|
||||
} from '@blocksuite/affine-block-note';
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import type {
|
||||
EdgelessColorPickerButton,
|
||||
PickColorEvent,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import {
|
||||
packColor,
|
||||
packColorsWithColorScheme,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import {
|
||||
type EditorMenuButton,
|
||||
renderToolbarSeparator,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
type ColorScheme,
|
||||
DefaultTheme,
|
||||
type NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
resolveColor,
|
||||
type StrokeStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
FeatureFlagService,
|
||||
NotificationProvider,
|
||||
SidebarExtensionIdentifier,
|
||||
TelemetryProvider,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { EditorLifeCycleExtension } from '@blocksuite/block-std';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
AutoHeightIcon,
|
||||
CornerIcon,
|
||||
CustomizedHeightIcon,
|
||||
LineStyleIcon,
|
||||
LinkedPageIcon,
|
||||
NoteShadowDuotoneIcon,
|
||||
ScissorsIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { html, LitElement, nothing, type TemplateResult } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
import countBy from 'lodash-es/countBy';
|
||||
import maxBy from 'lodash-es/maxBy';
|
||||
|
||||
import {
|
||||
type LineStyleEvent,
|
||||
LineStylesPanel,
|
||||
} from '../../edgeless/components/panel/line-styles-panel.js';
|
||||
import { getTooltipWithShortcut } from '../../edgeless/components/utils.js';
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import { SmallArrowDownIcon } from './icons.js';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const SIZE_LIST = [
|
||||
{ name: 'None', value: 0 },
|
||||
{ name: 'Small', value: 8 },
|
||||
{ name: 'Medium', value: 16 },
|
||||
{ name: 'Large', value: 24 },
|
||||
{ name: 'Huge', value: 32 },
|
||||
] as const;
|
||||
|
||||
const DisplayModeMap = {
|
||||
[NoteDisplayMode.DocAndEdgeless]: 'Both',
|
||||
[NoteDisplayMode.EdgelessOnly]: 'Edgeless',
|
||||
[NoteDisplayMode.DocOnly]: 'Page',
|
||||
} as const satisfies Record<NoteDisplayMode, string>;
|
||||
|
||||
function getMostCommonBackground(
|
||||
elements: NoteBlockModel[],
|
||||
colorScheme: ColorScheme
|
||||
): string {
|
||||
const colors = countBy(elements, (ele: NoteBlockModel) =>
|
||||
resolveColor(ele.background, colorScheme)
|
||||
);
|
||||
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
|
||||
return max
|
||||
? (max[0] as string)
|
||||
: resolveColor(DefaultTheme.noteBackgrounColor, colorScheme);
|
||||
}
|
||||
|
||||
export class EdgelessChangeNoteButton extends WithDisposable(LitElement) {
|
||||
get crud() {
|
||||
return this.edgeless.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
private readonly _setBackground = (background: string) => {
|
||||
this.notes.forEach(element => {
|
||||
this.crud.updateElement(element.id, { background });
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _setBorderRadius = (borderRadius: number) => {
|
||||
this.notes.forEach(note => {
|
||||
const props = {
|
||||
edgeless: {
|
||||
...note.edgeless,
|
||||
style: {
|
||||
...note.edgeless.style,
|
||||
borderRadius,
|
||||
},
|
||||
},
|
||||
};
|
||||
this.crud.updateElement(note.id, props);
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _setNoteScale = (scale: number) => {
|
||||
this.notes.forEach(note => {
|
||||
this.doc.updateBlock(note, () => {
|
||||
const bound = Bound.deserialize(note.xywh);
|
||||
const oldScale = note.edgeless.scale ?? 1;
|
||||
const ratio = scale / oldScale;
|
||||
bound.w *= ratio;
|
||||
bound.h *= ratio;
|
||||
const xywh = bound.serialize();
|
||||
note.xywh = xywh;
|
||||
note.edgeless.scale = scale;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
pickColor = (e: PickColorEvent) => {
|
||||
const field = 'background';
|
||||
|
||||
if (e.type === 'pick') {
|
||||
const color = e.detail.value;
|
||||
this.notes.forEach(element => {
|
||||
const props = packColor(field, color);
|
||||
this.crud.updateElement(element.id, props);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.notes.forEach(ele => ele[e.type === 'start' ? 'stash' : 'pop'](field));
|
||||
};
|
||||
|
||||
private get _advancedVisibilityEnabled() {
|
||||
return this.doc
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_advanced_block_visibility');
|
||||
}
|
||||
|
||||
private get doc() {
|
||||
return this.edgeless.doc;
|
||||
}
|
||||
|
||||
private _getScaleLabel(scale: number) {
|
||||
return Math.round(scale * 100) + '%';
|
||||
}
|
||||
|
||||
private _handleNoteSlicerButtonClick() {
|
||||
const surfaceService = this.edgeless.service;
|
||||
if (!surfaceService) return;
|
||||
|
||||
this.edgeless.slots.toggleNoteSlicer.emit();
|
||||
}
|
||||
|
||||
private _setCollapse() {
|
||||
this.doc.captureSync();
|
||||
this.notes.forEach(note => {
|
||||
const { collapse, collapsedHeight } = note.edgeless;
|
||||
|
||||
if (collapse) {
|
||||
this.doc.updateBlock(note, () => {
|
||||
note.edgeless.collapse = false;
|
||||
});
|
||||
} else if (collapsedHeight) {
|
||||
const { xywh, edgeless } = note;
|
||||
const bound = Bound.deserialize(xywh);
|
||||
bound.h = collapsedHeight * (edgeless.scale ?? 1);
|
||||
this.doc.updateBlock(note, () => {
|
||||
note.edgeless.collapse = true;
|
||||
note.xywh = bound.serialize();
|
||||
});
|
||||
}
|
||||
});
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _setDisplayMode(note: NoteBlockModel, newMode: NoteDisplayMode) {
|
||||
const oldMode = note.displayMode;
|
||||
this.edgeless.std.command.exec(changeNoteDisplayMode, {
|
||||
noteId: note.id,
|
||||
mode: newMode,
|
||||
stopCapture: true,
|
||||
});
|
||||
|
||||
// if change note to page only, should clear the selection
|
||||
if (newMode === NoteDisplayMode.DocOnly) {
|
||||
this.edgeless.service.selection.clear();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const clear = () => {
|
||||
this.doc.history.off('stack-item-added', addHandler);
|
||||
this.doc.history.off('stack-item-popped', popHandler);
|
||||
disposable.dispose();
|
||||
};
|
||||
const closeNotify = () => {
|
||||
abortController.abort();
|
||||
clear();
|
||||
};
|
||||
|
||||
const addHandler = this.doc.history.on('stack-item-added', closeNotify);
|
||||
const popHandler = this.doc.history.on('stack-item-popped', closeNotify);
|
||||
const disposable = this.edgeless.std
|
||||
.get(EditorLifeCycleExtension)
|
||||
.slots.unmounted.on(closeNotify);
|
||||
|
||||
const undo = () => {
|
||||
this.doc.undo();
|
||||
closeNotify();
|
||||
};
|
||||
|
||||
const viewInToc = () => {
|
||||
const sidebar = this.edgeless.std.getOptional(SidebarExtensionIdentifier);
|
||||
sidebar?.open('outline');
|
||||
closeNotify();
|
||||
};
|
||||
|
||||
const title =
|
||||
newMode !== NoteDisplayMode.EdgelessOnly
|
||||
? 'Note displayed in Page Mode'
|
||||
: 'Note removed from Page Mode';
|
||||
const message =
|
||||
newMode !== NoteDisplayMode.EdgelessOnly
|
||||
? 'Content added to your page.'
|
||||
: 'Content removed from your page.';
|
||||
|
||||
const notification = this.edgeless.std.getOptional(NotificationProvider);
|
||||
notification?.notify({
|
||||
title: title,
|
||||
message: `${message}. Find it in the TOC for quick navigation.`,
|
||||
accent: 'success',
|
||||
duration: 5 * 1000,
|
||||
footer: html`<div class=${styles.viewInPageNotifyFooter}>
|
||||
<button
|
||||
class=${styles.viewInPageNotifyFooterButton}
|
||||
@click=${undo}
|
||||
data-testid="undo-display-in-page"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
<button
|
||||
class=${styles.viewInPageNotifyFooterButton}
|
||||
@click=${viewInToc}
|
||||
data-testid="view-in-toc"
|
||||
>
|
||||
View in Toc
|
||||
</button>
|
||||
</div>`,
|
||||
abort: abortController.signal,
|
||||
onClose: () => {
|
||||
clear();
|
||||
},
|
||||
});
|
||||
|
||||
this.edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('NoteDisplayModeChanged', {
|
||||
page: 'whiteboard editor',
|
||||
segment: 'element toolbar',
|
||||
module: 'element toolbar',
|
||||
control: 'display mode',
|
||||
type: 'note',
|
||||
other: `from ${oldMode} to ${newMode}`,
|
||||
});
|
||||
}
|
||||
|
||||
private _setShadowType(shadowType: string) {
|
||||
this.notes.forEach(note => {
|
||||
const props = {
|
||||
edgeless: {
|
||||
...note.edgeless,
|
||||
style: {
|
||||
...note.edgeless.style,
|
||||
shadowType,
|
||||
},
|
||||
},
|
||||
};
|
||||
this.crud.updateElement(note.id, props);
|
||||
});
|
||||
}
|
||||
|
||||
private _setStrokeStyle(borderStyle: StrokeStyle) {
|
||||
this.notes.forEach(note => {
|
||||
const props = {
|
||||
edgeless: {
|
||||
...note.edgeless,
|
||||
style: {
|
||||
...note.edgeless.style,
|
||||
borderStyle,
|
||||
},
|
||||
},
|
||||
};
|
||||
this.crud.updateElement(note.id, props);
|
||||
});
|
||||
}
|
||||
|
||||
private _setStrokeWidth(borderSize: number) {
|
||||
this.notes.forEach(note => {
|
||||
const props = {
|
||||
edgeless: {
|
||||
...note.edgeless,
|
||||
style: {
|
||||
...note.edgeless.style,
|
||||
borderSize,
|
||||
},
|
||||
},
|
||||
};
|
||||
this.crud.updateElement(note.id, props);
|
||||
});
|
||||
}
|
||||
|
||||
private _setStyles({ type, value }: LineStyleEvent) {
|
||||
if (type === 'size') {
|
||||
this._setStrokeWidth(value);
|
||||
return;
|
||||
}
|
||||
if (type === 'lineStyle') {
|
||||
this._setStrokeStyle(value);
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
const len = this.notes.length;
|
||||
const note = this.notes[0];
|
||||
const { edgeless, displayMode } = note;
|
||||
const { shadowType, borderRadius, borderSize, borderStyle } =
|
||||
edgeless.style;
|
||||
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
|
||||
const background = getMostCommonBackground(this.notes, colorScheme);
|
||||
|
||||
const { collapse } = edgeless;
|
||||
const scale = edgeless.scale ?? 1;
|
||||
const currentMode = DisplayModeMap[displayMode];
|
||||
const onlyOne = len === 1;
|
||||
const isDocOnly = displayMode === NoteDisplayMode.DocOnly;
|
||||
|
||||
const hasPageBlockHeader = !!this.edgeless.std.getOptional(
|
||||
NoteConfigExtension.identifier
|
||||
)?.edgelessNoteHeader;
|
||||
|
||||
const theme = this.edgeless.std.get(ThemeProvider).theme;
|
||||
const buttonIconSize = { width: '20px', height: '20px' };
|
||||
const buttons = [
|
||||
onlyOne && this._advancedVisibilityEnabled
|
||||
? html`
|
||||
<span class="display-mode-button-label">Show in</span>
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Mode"
|
||||
.tooltip=${'Display mode'}
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
>
|
||||
<span class="label">${currentMode}</span>
|
||||
${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<note-display-mode-panel
|
||||
.displayMode=${displayMode}
|
||||
.onSelect=${(newMode: NoteDisplayMode) =>
|
||||
this._setDisplayMode(note, newMode)}
|
||||
>
|
||||
</note-display-mode-panel>
|
||||
</editor-menu-button>
|
||||
`
|
||||
: nothing,
|
||||
|
||||
onlyOne && !note.isPageBlock() && !this._advancedVisibilityEnabled
|
||||
? html`<editor-icon-button
|
||||
aria-label="Display In Page"
|
||||
.showTooltip=${displayMode === NoteDisplayMode.DocAndEdgeless}
|
||||
.tooltip=${'This note is part of Page Mode. Click to remove it from the page.'}
|
||||
data-testid="display-in-page"
|
||||
@click=${() =>
|
||||
this._setDisplayMode(
|
||||
note,
|
||||
displayMode === NoteDisplayMode.EdgelessOnly
|
||||
? NoteDisplayMode.DocAndEdgeless
|
||||
: NoteDisplayMode.EdgelessOnly
|
||||
)}
|
||||
>
|
||||
${LinkedPageIcon(buttonIconSize)}
|
||||
<span class="label"
|
||||
>${displayMode === NoteDisplayMode.EdgelessOnly
|
||||
? 'Display In Page'
|
||||
: 'Displayed In Page'}</span
|
||||
>
|
||||
</editor-icon-button>`
|
||||
: nothing,
|
||||
|
||||
isDocOnly
|
||||
? nothing
|
||||
: when(
|
||||
this.edgeless.doc
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_color_picker'),
|
||||
() => {
|
||||
const { type, colors } = packColorsWithColorScheme(
|
||||
colorScheme,
|
||||
background,
|
||||
note.background
|
||||
);
|
||||
|
||||
return html`
|
||||
<edgeless-color-picker-button
|
||||
class="background"
|
||||
.label=${'Background'}
|
||||
.pick=${this.pickColor}
|
||||
.color=${background}
|
||||
.colorPanelClass=${'small'}
|
||||
.colorType=${type}
|
||||
.colors=${colors}
|
||||
.theme=${colorScheme}
|
||||
.palettes=${DefaultTheme.NoteBackgroundColorPalettes}
|
||||
>
|
||||
</edgeless-color-picker-button>
|
||||
`;
|
||||
},
|
||||
() => html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Background"
|
||||
.tooltip=${'Background'}
|
||||
>
|
||||
<edgeless-color-button
|
||||
.color=${background}
|
||||
></edgeless-color-button>
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-color-panel
|
||||
class="small"
|
||||
.value=${background}
|
||||
.theme=${colorScheme}
|
||||
.palettes=${DefaultTheme.NoteBackgroundColorPalettes}
|
||||
@select=${this._setBackground}
|
||||
>
|
||||
</edgeless-color-panel>
|
||||
</editor-menu-button>
|
||||
`
|
||||
),
|
||||
|
||||
isDocOnly
|
||||
? nothing
|
||||
: html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'6px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Shadow style"
|
||||
.tooltip=${'Shadow style'}
|
||||
>
|
||||
${NoteShadowDuotoneIcon(buttonIconSize)}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-note-shadow-panel
|
||||
.theme=${theme}
|
||||
.value=${shadowType}
|
||||
.background=${background}
|
||||
.onSelect=${(value: string) => this._setShadowType(value)}
|
||||
>
|
||||
</edgeless-note-shadow-panel>
|
||||
</editor-menu-button>
|
||||
|
||||
<editor-menu-button
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Border style"
|
||||
.tooltip=${'Border style'}
|
||||
>
|
||||
${LineStyleIcon(buttonIconSize)}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-orientation="horizontal">
|
||||
${LineStylesPanel({
|
||||
selectedLineSize: borderSize,
|
||||
selectedLineStyle: borderStyle,
|
||||
onClick: event => this._setStyles(event),
|
||||
})}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
|
||||
<editor-menu-button
|
||||
${ref(this._cornersPanelRef)}
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="Corners" .tooltip=${'Corners'}>
|
||||
${CornerIcon(buttonIconSize)}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-size-panel
|
||||
.size=${borderRadius}
|
||||
.sizeList=${SIZE_LIST}
|
||||
.minSize=${0}
|
||||
.onSelect=${(size: number) => this._setBorderRadius(size)}
|
||||
.onPopperCose=${() => this._cornersPanelRef.value?.hide()}
|
||||
>
|
||||
</edgeless-size-panel>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
|
||||
onlyOne && this._advancedVisibilityEnabled
|
||||
? html`
|
||||
<editor-icon-button
|
||||
aria-label="Slicer"
|
||||
.tooltip=${getTooltipWithShortcut('Cutting mode', '-')}
|
||||
.active=${this.enableNoteSlicer}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() => this._handleNoteSlicerButtonClick()}
|
||||
>
|
||||
${ScissorsIcon()}
|
||||
</editor-icon-button>
|
||||
`
|
||||
: nothing,
|
||||
|
||||
onlyOne ? this.quickConnectButton : nothing,
|
||||
|
||||
!this.notes[0].isPageBlock() || !hasPageBlockHeader
|
||||
? html`<editor-icon-button
|
||||
aria-label="Size"
|
||||
data-testid="edgeless-note-auto-height"
|
||||
.tooltip=${collapse ? 'Auto height' : 'Customized height'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() => this._setCollapse()}
|
||||
>
|
||||
${collapse ? AutoHeightIcon() : CustomizedHeightIcon()}
|
||||
</editor-icon-button>`
|
||||
: nothing,
|
||||
|
||||
html`
|
||||
<editor-menu-button
|
||||
${ref(this._scalePanelRef)}
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Scale"
|
||||
.tooltip=${'Scale'}
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
.iconContainerWidth=${'65px'}
|
||||
>
|
||||
<span class="label">${this._getScaleLabel(scale)}</span
|
||||
>${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-scale-panel
|
||||
.scale=${Math.round(scale * 100)}
|
||||
.onSelect=${(scale: number) => this._setNoteScale(scale)}
|
||||
.onPopperCose=${() => this._scalePanelRef.value?.hide()}
|
||||
></edgeless-scale-panel>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
];
|
||||
|
||||
return join(
|
||||
buttons.filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
);
|
||||
}
|
||||
|
||||
private accessor _cornersPanelRef: Ref<EditorMenuButton> = createRef();
|
||||
|
||||
private accessor _scalePanelRef: Ref<EditorMenuButton> = createRef();
|
||||
|
||||
@query('edgeless-color-picker-button.background')
|
||||
accessor backgroundButton!: EdgelessColorPickerButton;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor enableNoteSlicer!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor notes: NoteBlockModel[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor quickConnectButton!: TemplateResult<1> | typeof nothing;
|
||||
}
|
||||
|
||||
export function renderNoteButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
notes?: NoteBlockModel[],
|
||||
quickConnectButton?: TemplateResult<1>[]
|
||||
) {
|
||||
if (!notes?.length) return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-change-note-button
|
||||
.notes=${notes}
|
||||
.edgeless=${edgeless}
|
||||
.enableNoteSlicer=${false}
|
||||
.quickConnectButton=${quickConnectButton?.pop() ?? nothing}
|
||||
>
|
||||
</edgeless-change-note-button>
|
||||
`;
|
||||
}
|
||||
+509
@@ -0,0 +1,509 @@
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import type {
|
||||
EdgelessColorPickerButton,
|
||||
PickColorEvent,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import {
|
||||
packColor,
|
||||
packColorsWithColorScheme,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
|
||||
import type {
|
||||
Color,
|
||||
ColorScheme,
|
||||
ShapeElementModel,
|
||||
ShapeProps,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
DefaultTheme,
|
||||
FontFamily,
|
||||
getShapeName,
|
||||
getShapeRadius,
|
||||
getShapeType,
|
||||
isTransparent,
|
||||
LineWidth,
|
||||
MindmapElementModel,
|
||||
resolveColor,
|
||||
ShapeStyle,
|
||||
StrokeStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
AddTextIcon,
|
||||
ShapeIcon,
|
||||
StyleGeneralIcon,
|
||||
StyleScribbleIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { cache } from 'lit/directives/cache.js';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
import countBy from 'lodash-es/countBy';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import maxBy from 'lodash-es/maxBy';
|
||||
|
||||
import {
|
||||
type LineStyleEvent,
|
||||
LineStylesPanel,
|
||||
} from '../../edgeless/components/panel/line-styles-panel.js';
|
||||
import type { EdgelessShapePanel } from '../../edgeless/components/panel/shape-panel.js';
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import type { ShapeToolOption } from '../../edgeless/gfx-tool/shape-tool.js';
|
||||
import { mountShapeTextEditor } from '../../edgeless/utils/text.js';
|
||||
import { SmallArrowDownIcon } from './icons.js';
|
||||
|
||||
const changeShapeButtonStyles = [
|
||||
css`
|
||||
.edgeless-component-line-size-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.edgeless-component-line-size-button div {
|
||||
border-radius: 50%;
|
||||
background-color: var(--affine-icon-color);
|
||||
}
|
||||
|
||||
.edgeless-component-line-size-button.size-s div {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
.edgeless-component-line-size-button.size-l div {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
function getMostCommonFillColor(
|
||||
elements: ShapeElementModel[],
|
||||
colorScheme: ColorScheme
|
||||
): string {
|
||||
const colors = countBy(elements, (ele: ShapeElementModel) =>
|
||||
ele.filled ? resolveColor(ele.fillColor, colorScheme) : 'transparent'
|
||||
);
|
||||
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
|
||||
return max
|
||||
? (max[0] as string)
|
||||
: resolveColor(DefaultTheme.shapeFillColor, colorScheme);
|
||||
}
|
||||
|
||||
function getMostCommonStrokeColor(
|
||||
elements: ShapeElementModel[],
|
||||
colorScheme: ColorScheme
|
||||
): string {
|
||||
const colors = countBy(elements, (ele: ShapeElementModel) =>
|
||||
resolveColor(ele.strokeColor, colorScheme)
|
||||
);
|
||||
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
|
||||
return max
|
||||
? (max[0] as string)
|
||||
: resolveColor(DefaultTheme.shapeStrokeColor, colorScheme);
|
||||
}
|
||||
|
||||
function getMostCommonShape(
|
||||
elements: ShapeElementModel[]
|
||||
): ShapeToolOption['shapeName'] | null {
|
||||
const shapeTypes = countBy(elements, (ele: ShapeElementModel) =>
|
||||
getShapeName(ele.shapeType, ele.radius)
|
||||
);
|
||||
const max = maxBy(Object.entries(shapeTypes), ([_k, count]) => count);
|
||||
return max ? (max[0] as ShapeToolOption['shapeName']) : null;
|
||||
}
|
||||
|
||||
function getMostCommonLineSize(elements: ShapeElementModel[]): LineWidth {
|
||||
const sizes = countBy(elements, (ele: ShapeElementModel) => ele.strokeWidth);
|
||||
const max = maxBy(Object.entries(sizes), ([_k, count]) => count);
|
||||
return max ? (Number(max[0]) as LineWidth) : LineWidth.Four;
|
||||
}
|
||||
|
||||
function getMostCommonLineStyle(elements: ShapeElementModel[]): StrokeStyle {
|
||||
const sizes = countBy(elements, (ele: ShapeElementModel) => ele.strokeStyle);
|
||||
const max = maxBy(Object.entries(sizes), ([_k, count]) => count);
|
||||
return max ? (max[0] as StrokeStyle) : StrokeStyle.Solid;
|
||||
}
|
||||
|
||||
function getMostCommonShapeStyle(elements: ShapeElementModel[]): ShapeStyle {
|
||||
const roughnesses = countBy(
|
||||
elements,
|
||||
(ele: ShapeElementModel) => ele.shapeStyle
|
||||
);
|
||||
const max = maxBy(Object.entries(roughnesses), ([_k, count]) => count);
|
||||
return max ? (max[0] as ShapeStyle) : ShapeStyle.Scribbled;
|
||||
}
|
||||
|
||||
export class EdgelessChangeShapeButton extends WithDisposable(LitElement) {
|
||||
static override styles = [changeShapeButtonStyles];
|
||||
|
||||
private readonly _setShapeFillColor = (e: ColorEvent) => {
|
||||
const fillColor = e.detail.value;
|
||||
const filled = !isTransparent(fillColor);
|
||||
const color = this._getTextColor(fillColor, filled);
|
||||
this.elements.forEach(ele =>
|
||||
this.crud.updateElement(ele.id, { filled, fillColor, color })
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _setShapeStrokeColor = (e: ColorEvent) => {
|
||||
const strokeColor = e.detail.value;
|
||||
this.elements.forEach(ele =>
|
||||
this.crud.updateElement(ele.id, { strokeColor })
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _setShapeStyles = ({ type, value }: LineStyleEvent) => {
|
||||
if (type === 'size') {
|
||||
this._setShapeStrokeWidth(value);
|
||||
return;
|
||||
}
|
||||
if (type === 'lineStyle') {
|
||||
this._setShapeStrokeStyle(value);
|
||||
}
|
||||
};
|
||||
|
||||
get service() {
|
||||
return this.edgeless.service;
|
||||
}
|
||||
|
||||
get crud() {
|
||||
return this.edgeless.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
private _addText() {
|
||||
mountShapeTextEditor(this.elements[0], this.edgeless);
|
||||
}
|
||||
|
||||
private _getTextColor(fillColor: Color, isNotTransparent = false) {
|
||||
// When the shape is filled with black color, the text color should be white.
|
||||
// When the shape is transparent, the text color should be set according to the theme.
|
||||
// Otherwise, the text color should be black.
|
||||
|
||||
if (isNotTransparent) {
|
||||
if (isEqual(fillColor, DefaultTheme.black)) {
|
||||
return DefaultTheme.white;
|
||||
} else if (isEqual(fillColor, DefaultTheme.white)) {
|
||||
return DefaultTheme.black;
|
||||
}
|
||||
}
|
||||
|
||||
return DefaultTheme.black;
|
||||
}
|
||||
|
||||
private _setShapeStrokeStyle(strokeStyle: StrokeStyle) {
|
||||
this.elements.forEach(ele =>
|
||||
this.crud.updateElement(ele.id, { strokeStyle })
|
||||
);
|
||||
}
|
||||
|
||||
private _setShapeStrokeWidth(strokeWidth: number) {
|
||||
this.elements.forEach(ele =>
|
||||
this.crud.updateElement(ele.id, { strokeWidth })
|
||||
);
|
||||
}
|
||||
|
||||
private _setShapeStyle(shapeStyle: ShapeStyle) {
|
||||
const fontFamily =
|
||||
shapeStyle === ShapeStyle.General ? FontFamily.Inter : FontFamily.Kalam;
|
||||
|
||||
this.elements.forEach(ele => {
|
||||
this.crud.updateElement(ele.id, { shapeStyle, fontFamily });
|
||||
});
|
||||
}
|
||||
|
||||
private _showAddButtonOrTextMenu() {
|
||||
if (this.elements.length === 1 && !this.elements[0].text) {
|
||||
return 'button';
|
||||
}
|
||||
if (!this.elements.some(e => !e.text)) {
|
||||
return 'menu';
|
||||
}
|
||||
return 'nothing';
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const _disposables = this._disposables;
|
||||
|
||||
_disposables.add(
|
||||
this._shapePanel.slots.select.on(shapeName => {
|
||||
this.edgeless.doc.captureSync();
|
||||
this.elements.forEach(element => {
|
||||
this.crud.updateElement(element.id, {
|
||||
shapeType: getShapeType(shapeName),
|
||||
radius: getShapeRadius(shapeName),
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
pickColor<K extends keyof Pick<ShapeProps, 'fillColor' | 'strokeColor'>>(
|
||||
field: K
|
||||
) {
|
||||
return (e: PickColorEvent) => {
|
||||
if (e.type === 'pick') {
|
||||
const color = e.detail.value;
|
||||
this.elements.forEach(ele => {
|
||||
const props = packColor(field, color);
|
||||
// If `filled` can be set separately, this logic can be removed
|
||||
if (field === 'fillColor' && !ele.filled) {
|
||||
Object.assign(props, { filled: true });
|
||||
}
|
||||
this.crud.updateElement(ele.id, props);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.elements.forEach(ele =>
|
||||
ele[e.type === 'start' ? 'stash' : 'pop'](field)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
override render() {
|
||||
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
|
||||
const elements = this.elements;
|
||||
const selectedShape = getMostCommonShape(elements);
|
||||
const selectedFillColor = getMostCommonFillColor(elements, colorScheme);
|
||||
const selectedStrokeColor = getMostCommonStrokeColor(elements, colorScheme);
|
||||
const selectedLineSize = getMostCommonLineSize(elements);
|
||||
const selectedLineStyle = getMostCommonLineStyle(elements);
|
||||
const selectedShapeStyle = getMostCommonShapeStyle(elements);
|
||||
const iconSize = { width: '20px', height: '20px' };
|
||||
|
||||
return join(
|
||||
[
|
||||
html`
|
||||
<editor-menu-button
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Switch type"
|
||||
.tooltip=${'Switch type'}
|
||||
>
|
||||
${ShapeIcon(iconSize)}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-shape-panel
|
||||
.selectedShape=${selectedShape}
|
||||
.shapeStyle=${selectedShapeStyle}
|
||||
>
|
||||
</edgeless-shape-panel>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
|
||||
html`
|
||||
<editor-menu-button
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="Style" .tooltip=${'Style'}>
|
||||
${cache(
|
||||
selectedShapeStyle === ShapeStyle.General
|
||||
? StyleGeneralIcon(iconSize)
|
||||
: StyleScribbleIcon(iconSize)
|
||||
)}
|
||||
${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-shape-style-panel
|
||||
.value=${selectedShapeStyle}
|
||||
.onSelect=${(value: ShapeStyle) => this._setShapeStyle(value)}
|
||||
>
|
||||
</edgeless-shape-style-panel>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
|
||||
when(
|
||||
this.edgeless.doc
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_color_picker'),
|
||||
() => {
|
||||
const { type, colors } = packColorsWithColorScheme(
|
||||
colorScheme,
|
||||
selectedFillColor,
|
||||
elements[0].fillColor
|
||||
);
|
||||
|
||||
return html`
|
||||
<edgeless-color-picker-button
|
||||
class="fill-color"
|
||||
.label=${'Fill color'}
|
||||
.pick=${this.pickColor('fillColor')}
|
||||
.color=${selectedFillColor}
|
||||
.colors=${colors}
|
||||
.colorType=${type}
|
||||
.theme=${colorScheme}
|
||||
>
|
||||
</edgeless-color-picker-button>
|
||||
`;
|
||||
},
|
||||
() => html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Fill color"
|
||||
.tooltip=${'Fill color'}
|
||||
>
|
||||
<edgeless-color-button
|
||||
.color=${selectedFillColor}
|
||||
></edgeless-color-button>
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-color-panel
|
||||
role="listbox"
|
||||
aria-label="Fill colors"
|
||||
.value=${selectedFillColor}
|
||||
.theme=${colorScheme}
|
||||
@select=${this._setShapeFillColor}
|
||||
>
|
||||
</edgeless-color-panel>
|
||||
</editor-menu-button>
|
||||
`
|
||||
),
|
||||
|
||||
when(
|
||||
this.edgeless.doc
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_color_picker'),
|
||||
() => {
|
||||
const { type, colors } = packColorsWithColorScheme(
|
||||
colorScheme,
|
||||
selectedStrokeColor,
|
||||
elements[0].strokeColor
|
||||
);
|
||||
|
||||
return html`
|
||||
<edgeless-color-picker-button
|
||||
class="border-style"
|
||||
.label=${'Border style'}
|
||||
.pick=${this.pickColor('strokeColor')}
|
||||
.color=${selectedStrokeColor}
|
||||
.colors=${colors}
|
||||
.colorType=${type}
|
||||
.theme=${colorScheme}
|
||||
.hollowCircle=${true}
|
||||
>
|
||||
<div
|
||||
slot="other"
|
||||
class="line-styles"
|
||||
style=${styleMap({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
${LineStylesPanel({
|
||||
selectedLineSize: selectedLineSize,
|
||||
selectedLineStyle: selectedLineStyle,
|
||||
onClick: this._setShapeStyles,
|
||||
})}
|
||||
</div>
|
||||
<editor-toolbar-separator
|
||||
slot="separator"
|
||||
data-orientation="horizontal"
|
||||
></editor-toolbar-separator>
|
||||
</edgeless-color-picker-button>
|
||||
`;
|
||||
},
|
||||
() => html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Border style"
|
||||
.tooltip=${'Border style'}
|
||||
>
|
||||
<edgeless-color-button
|
||||
.color=${selectedStrokeColor}
|
||||
.hollowCircle=${true}
|
||||
></edgeless-color-button>
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<stroke-style-panel
|
||||
.theme=${colorScheme}
|
||||
.hollowCircle=${true}
|
||||
.strokeWidth=${selectedLineSize}
|
||||
.strokeStyle=${selectedLineStyle}
|
||||
.strokeColor=${selectedStrokeColor}
|
||||
.setStrokeStyle=${this._setShapeStyles}
|
||||
.setStrokeColor=${this._setShapeStrokeColor}
|
||||
>
|
||||
</stroke-style-panel>
|
||||
</editor-menu-button>
|
||||
`
|
||||
),
|
||||
|
||||
choose<string, TemplateResult<1> | typeof nothing>(
|
||||
this._showAddButtonOrTextMenu(),
|
||||
[
|
||||
[
|
||||
'button',
|
||||
() => html`
|
||||
<editor-icon-button
|
||||
aria-label="Add text"
|
||||
.tooltip="${'Add text'}"
|
||||
.iconSize="${'20px'}"
|
||||
@click=${this._addText}
|
||||
>
|
||||
${AddTextIcon()}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
],
|
||||
[
|
||||
'menu',
|
||||
() => html`
|
||||
<edgeless-change-text-menu
|
||||
.elementType="${'shape'}"
|
||||
.elements=${elements}
|
||||
.edgeless=${this.edgeless}
|
||||
></edgeless-change-text-menu>
|
||||
`,
|
||||
],
|
||||
['nothing', () => nothing],
|
||||
]
|
||||
),
|
||||
].filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
);
|
||||
}
|
||||
|
||||
@query('edgeless-shape-panel')
|
||||
private accessor _shapePanel!: EdgelessShapePanel;
|
||||
|
||||
@query('edgeless-color-picker-button.border-style')
|
||||
accessor borderStyleButton!: EdgelessColorPickerButton;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor elements: ShapeElementModel[] = [];
|
||||
|
||||
@query('edgeless-color-picker-button.fill-color')
|
||||
accessor fillColorButton!: EdgelessColorPickerButton;
|
||||
}
|
||||
|
||||
export function renderChangeShapeButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
elements?: ShapeElementModel[]
|
||||
) {
|
||||
if (!elements?.length) return nothing;
|
||||
if (elements.some(e => e.group instanceof MindmapElementModel))
|
||||
return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-change-shape-button .elements=${elements} .edgeless=${edgeless}>
|
||||
</edgeless-change-shape-button>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TextElementModel } from '@blocksuite/affine-model';
|
||||
import { html, nothing } from 'lit';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
|
||||
export function renderChangeTextButton(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
elements?: TextElementModel[]
|
||||
) {
|
||||
if (!elements?.length) return nothing;
|
||||
|
||||
return html`
|
||||
<edgeless-change-text-menu
|
||||
.elementType=${'text'}
|
||||
.elements=${elements}
|
||||
.edgeless=${edgeless}
|
||||
></edgeless-change-text-menu>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import {
|
||||
ConnectorUtils,
|
||||
EdgelessCRUDIdentifier,
|
||||
normalizeShapeBound,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type {
|
||||
EdgelessColorPickerButton,
|
||||
PickColorEvent,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import {
|
||||
packColor,
|
||||
packColorsWithColorScheme,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
type ColorScheme,
|
||||
ConnectorElementModel,
|
||||
DefaultTheme,
|
||||
EdgelessTextBlockModel,
|
||||
FontFamily,
|
||||
FontStyle,
|
||||
FontWeight,
|
||||
resolveColor,
|
||||
ShapeElementModel,
|
||||
type SurfaceTextModel,
|
||||
type SurfaceTextModelMap,
|
||||
TextAlign,
|
||||
TextElementModel,
|
||||
type TextStyleProps,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import type { ColorEvent } from '@blocksuite/affine-shared/utils';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
import countBy from 'lodash-es/countBy';
|
||||
import maxBy from 'lodash-es/maxBy';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import { SmallArrowDownIcon } from './icons.js';
|
||||
|
||||
const FONT_SIZE_LIST = [
|
||||
{ value: 16 },
|
||||
{ value: 24 },
|
||||
{ value: 32 },
|
||||
{ value: 40 },
|
||||
{ value: 64 },
|
||||
{ value: 128 },
|
||||
] as const;
|
||||
|
||||
const FONT_WEIGHT_CHOOSE: [FontWeight, () => string][] = [
|
||||
[FontWeight.Light, () => 'Light'],
|
||||
[FontWeight.Regular, () => 'Regular'],
|
||||
[FontWeight.SemiBold, () => 'Semibold'],
|
||||
] as const;
|
||||
|
||||
const FONT_STYLE_CHOOSE: [FontStyle, () => string | typeof nothing][] = [
|
||||
[FontStyle.Normal, () => nothing],
|
||||
[FontStyle.Italic, () => 'Italic'],
|
||||
] as const;
|
||||
|
||||
const iconSize = { width: '20px', height: '20px' };
|
||||
const TEXT_ALIGN_CHOOSE: [TextAlign, () => TemplateResult<1>][] = [
|
||||
[TextAlign.Left, () => TextAlignLeftIcon(iconSize)],
|
||||
[TextAlign.Center, () => TextAlignCenterIcon(iconSize)],
|
||||
[TextAlign.Right, () => TextAlignRightIcon(iconSize)],
|
||||
] as const;
|
||||
|
||||
function countByField<K extends keyof Omit<TextStyleProps, 'color'>>(
|
||||
elements: SurfaceTextModel[],
|
||||
field: K
|
||||
) {
|
||||
return countBy(elements, element => extractField(element, field));
|
||||
}
|
||||
|
||||
function extractField<K extends keyof Omit<TextStyleProps, 'color'>>(
|
||||
element: SurfaceTextModel,
|
||||
field: K
|
||||
) {
|
||||
//TODO: It's not a very good handling method.
|
||||
// The edgeless-change-text-menu should be refactored into a widget to allow external registration of its own logic.
|
||||
if (element instanceof EdgelessTextBlockModel) {
|
||||
return field === 'fontSize'
|
||||
? null
|
||||
: (element[field as keyof EdgelessTextBlockModel] as TextStyleProps[K]);
|
||||
}
|
||||
return (
|
||||
element instanceof ConnectorElementModel
|
||||
? element.labelStyle[field]
|
||||
: element[field]
|
||||
) as TextStyleProps[K];
|
||||
}
|
||||
|
||||
function getMostCommonValue<K extends keyof Omit<TextStyleProps, 'color'>>(
|
||||
elements: SurfaceTextModel[],
|
||||
field: K
|
||||
) {
|
||||
const values = countByField(elements, field);
|
||||
return maxBy(Object.entries(values), ([_k, count]) => count);
|
||||
}
|
||||
|
||||
function getMostCommonAlign(elements: SurfaceTextModel[]) {
|
||||
const max = getMostCommonValue(elements, 'textAlign');
|
||||
return max ? (max[0] as TextAlign) : TextAlign.Left;
|
||||
}
|
||||
|
||||
function getMostCommonColor(
|
||||
elements: SurfaceTextModel[],
|
||||
colorScheme: ColorScheme
|
||||
): string {
|
||||
const colors = countBy(elements, (ele: SurfaceTextModel) => {
|
||||
const color =
|
||||
ele instanceof ConnectorElementModel ? ele.labelStyle.color : ele.color;
|
||||
return resolveColor(color, colorScheme);
|
||||
});
|
||||
const max = maxBy(Object.entries(colors), ([_k, count]) => count);
|
||||
return max
|
||||
? (max[0] as string)
|
||||
: resolveColor(DefaultTheme.textColor, colorScheme);
|
||||
}
|
||||
|
||||
function getMostCommonFontFamily(elements: SurfaceTextModel[]) {
|
||||
const max = getMostCommonValue(elements, 'fontFamily');
|
||||
return max ? (max[0] as FontFamily) : FontFamily.Inter;
|
||||
}
|
||||
|
||||
function getMostCommonFontSize(elements: SurfaceTextModel[]) {
|
||||
const max = getMostCommonValue(elements, 'fontSize');
|
||||
return max ? Number(max[0]) : FONT_SIZE_LIST[0].value;
|
||||
}
|
||||
|
||||
function getMostCommonFontStyle(elements: SurfaceTextModel[]) {
|
||||
const max = getMostCommonValue(elements, 'fontStyle');
|
||||
return max ? (max[0] as FontStyle) : FontStyle.Normal;
|
||||
}
|
||||
|
||||
function getMostCommonFontWeight(elements: SurfaceTextModel[]) {
|
||||
const max = getMostCommonValue(elements, 'fontWeight');
|
||||
return max ? (max[0] as FontWeight) : FontWeight.Regular;
|
||||
}
|
||||
|
||||
function buildProps(
|
||||
element: SurfaceTextModel,
|
||||
props: { [K in keyof TextStyleProps]?: TextStyleProps[K] }
|
||||
) {
|
||||
if (element instanceof ConnectorElementModel) {
|
||||
return {
|
||||
labelStyle: {
|
||||
...element.labelStyle,
|
||||
...props,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { ...props };
|
||||
}
|
||||
|
||||
export class EdgelessChangeTextMenu extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: inherit;
|
||||
align-items: inherit;
|
||||
justify-content: inherit;
|
||||
gap: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
get crud() {
|
||||
return this.edgeless.std.get(EdgelessCRUDIdentifier);
|
||||
}
|
||||
|
||||
private readonly _setFontFamily = (fontFamily: FontFamily) => {
|
||||
const currentFontWeight = getMostCommonFontWeight(this.elements);
|
||||
const fontWeight = TextUtils.isFontWeightSupported(
|
||||
fontFamily,
|
||||
currentFontWeight
|
||||
)
|
||||
? currentFontWeight
|
||||
: FontWeight.Regular;
|
||||
const currentFontStyle = getMostCommonFontStyle(this.elements);
|
||||
const fontStyle = TextUtils.isFontStyleSupported(
|
||||
fontFamily,
|
||||
currentFontStyle
|
||||
)
|
||||
? currentFontStyle
|
||||
: FontStyle.Normal;
|
||||
|
||||
const props = { fontFamily, fontWeight, fontStyle };
|
||||
this.elements.forEach(element => {
|
||||
this.crud.updateElement(element.id, buildProps(element, props));
|
||||
this._updateElementBound(element);
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _setFontSize = (fontSize: number) => {
|
||||
const props = { fontSize };
|
||||
this.elements.forEach(element => {
|
||||
this.crud.updateElement(element.id, buildProps(element, props));
|
||||
this._updateElementBound(element);
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _setFontWeightAndStyle = (
|
||||
fontWeight: FontWeight,
|
||||
fontStyle: FontStyle
|
||||
) => {
|
||||
const props = { fontWeight, fontStyle };
|
||||
this.elements.forEach(element => {
|
||||
this.crud.updateElement(element.id, buildProps(element, props));
|
||||
this._updateElementBound(element);
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _setTextAlign = (textAlign: TextAlign) => {
|
||||
const props = { textAlign };
|
||||
this.elements.forEach(element => {
|
||||
this.crud.updateElement(element.id, buildProps(element, props));
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _setTextColor = (e: ColorEvent) => {
|
||||
const color = e.detail.value;
|
||||
const props = { color };
|
||||
this.elements.forEach(element => {
|
||||
this.crud.updateElement(element.id, buildProps(element, props));
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _updateElementBound = (element: SurfaceTextModel) => {
|
||||
const elementType = this.elementType;
|
||||
if (elementType === 'text' && element instanceof TextElementModel) {
|
||||
// the change of font family will change the bound of the text
|
||||
const {
|
||||
text: yText,
|
||||
fontFamily,
|
||||
fontStyle,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
hasMaxWidth,
|
||||
} = element;
|
||||
const newBound = TextUtils.normalizeTextBound(
|
||||
{
|
||||
yText,
|
||||
fontFamily,
|
||||
fontStyle,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
hasMaxWidth,
|
||||
},
|
||||
Bound.fromXYWH(element.deserializedXYWH)
|
||||
);
|
||||
this.crud.updateElement(element.id, {
|
||||
xywh: newBound.serialize(),
|
||||
});
|
||||
} else if (
|
||||
elementType === 'connector' &&
|
||||
ConnectorUtils.isConnectorWithLabel(element)
|
||||
) {
|
||||
const {
|
||||
text,
|
||||
labelXYWH,
|
||||
labelStyle: { fontFamily, fontStyle, fontSize, fontWeight },
|
||||
labelConstraints: { hasMaxWidth, maxWidth },
|
||||
} = element as ConnectorElementModel;
|
||||
const prevBounds = Bound.fromXYWH(labelXYWH || [0, 0, 16, 16]);
|
||||
const center = prevBounds.center;
|
||||
const bounds = TextUtils.normalizeTextBound(
|
||||
{
|
||||
yText: text!,
|
||||
fontFamily,
|
||||
fontStyle,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
hasMaxWidth,
|
||||
maxWidth,
|
||||
},
|
||||
prevBounds
|
||||
);
|
||||
bounds.center = center;
|
||||
this.crud.updateElement(element.id, {
|
||||
labelXYWH: bounds.toXYWH(),
|
||||
});
|
||||
} else if (
|
||||
elementType === 'shape' &&
|
||||
element instanceof ShapeElementModel
|
||||
) {
|
||||
const newBound = normalizeShapeBound(
|
||||
element,
|
||||
Bound.fromXYWH(element.deserializedXYWH)
|
||||
);
|
||||
this.crud.updateElement(element.id, {
|
||||
xywh: newBound.serialize(),
|
||||
});
|
||||
}
|
||||
// no need to update the bound of edgeless text block, which updates itself using ResizeObserver
|
||||
};
|
||||
|
||||
pickColor = (e: PickColorEvent) => {
|
||||
if (e.type === 'pick') {
|
||||
const color = e.detail.value;
|
||||
this.elements.forEach(element => {
|
||||
const props = packColor('color', color);
|
||||
this.crud.updateElement(element.id, buildProps(element, props));
|
||||
this._updateElementBound(element);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = this.elementType === 'connector' ? 'labelStyle' : 'color';
|
||||
this.elements.forEach(ele => {
|
||||
ele[e.type === 'start' ? 'stash' : 'pop'](key as 'color');
|
||||
});
|
||||
};
|
||||
|
||||
get service() {
|
||||
return this.edgeless.service;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const colorScheme = this.edgeless.surface.renderer.getColorScheme();
|
||||
const elements = this.elements;
|
||||
const selectedAlign = getMostCommonAlign(elements);
|
||||
const selectedColor = getMostCommonColor(elements, colorScheme);
|
||||
const selectedFontFamily = getMostCommonFontFamily(elements);
|
||||
const selectedFontSize = Math.trunc(getMostCommonFontSize(elements));
|
||||
const selectedFontStyle = getMostCommonFontStyle(elements);
|
||||
const selectedFontWeight = getMostCommonFontWeight(elements);
|
||||
const matchFontFaces =
|
||||
TextUtils.getFontFacesByFontFamily(selectedFontFamily);
|
||||
const fontStyleBtnDisabled =
|
||||
matchFontFaces.length === 1 &&
|
||||
matchFontFaces[0].style === selectedFontStyle &&
|
||||
matchFontFaces[0].weight === selectedFontWeight;
|
||||
const palettes =
|
||||
this.elementType === 'shape'
|
||||
? DefaultTheme.ShapeTextColorPalettes
|
||||
: DefaultTheme.Palettes;
|
||||
|
||||
return join(
|
||||
[
|
||||
html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Font"
|
||||
.tooltip=${'Font'}
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
.iconContainerWidth=${'40px'}
|
||||
>
|
||||
<span
|
||||
class="label padding0"
|
||||
style=${`font-family: ${TextUtils.wrapFontFamily(selectedFontFamily)}`}
|
||||
>Aa</span
|
||||
>${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-font-family-panel
|
||||
.value=${selectedFontFamily}
|
||||
.onSelect=${this._setFontFamily}
|
||||
></edgeless-font-family-panel>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
|
||||
when(
|
||||
this.edgeless.doc
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_color_picker'),
|
||||
() => {
|
||||
const { type, colors } = packColorsWithColorScheme(
|
||||
colorScheme,
|
||||
selectedColor,
|
||||
elements[0] instanceof ConnectorElementModel
|
||||
? elements[0].labelStyle.color
|
||||
: elements[0].color
|
||||
);
|
||||
|
||||
return html`
|
||||
<edgeless-color-picker-button
|
||||
class="text-color"
|
||||
.label="${'Text color'}"
|
||||
.pick=${this.pickColor}
|
||||
.isText=${true}
|
||||
.color=${selectedColor}
|
||||
.colors=${colors}
|
||||
.colorType=${type}
|
||||
.theme=${colorScheme}
|
||||
.palettes=${palettes}
|
||||
>
|
||||
</edgeless-color-picker-button>
|
||||
`;
|
||||
},
|
||||
() => html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Text color"
|
||||
.tooltip=${'Text color'}
|
||||
>
|
||||
<edgeless-text-color-icon
|
||||
.color=${selectedColor}
|
||||
></edgeless-text-color-icon>
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-color-panel
|
||||
.value=${selectedColor}
|
||||
.theme=${colorScheme}
|
||||
.palettes=${palettes}
|
||||
@select=${this._setTextColor}
|
||||
></edgeless-color-panel>
|
||||
</editor-menu-button>
|
||||
`
|
||||
),
|
||||
|
||||
html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Font style"
|
||||
.tooltip=${'Font style'}
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
.iconContainerWidth=${'90px'}
|
||||
.disabled=${fontStyleBtnDisabled}
|
||||
>
|
||||
<span class="label ellipsis">
|
||||
${choose(selectedFontWeight, FONT_WEIGHT_CHOOSE)}
|
||||
${choose(selectedFontStyle, FONT_STYLE_CHOOSE)}
|
||||
</span>
|
||||
${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-font-weight-and-style-panel
|
||||
.fontFamily=${selectedFontFamily}
|
||||
.fontWeight=${selectedFontWeight}
|
||||
.fontStyle=${selectedFontStyle}
|
||||
.onSelect=${this._setFontWeightAndStyle}
|
||||
></edgeless-font-weight-and-style-panel>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
|
||||
this.elementType === 'edgeless-text'
|
||||
? nothing
|
||||
: html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Font size"
|
||||
.tooltip=${'Font size'}
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
.iconContainerWidth=${'60px'}
|
||||
>
|
||||
<span class="label">${selectedFontSize}</span>
|
||||
${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-size-panel
|
||||
data-type="check"
|
||||
.size=${selectedFontSize}
|
||||
.sizeList=${FONT_SIZE_LIST}
|
||||
.onSelect=${this._setFontSize}
|
||||
></edgeless-size-panel>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
|
||||
html`
|
||||
<editor-menu-button
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Alignment"
|
||||
.tooltip=${'Alignment'}
|
||||
>
|
||||
${choose(selectedAlign, TEXT_ALIGN_CHOOSE)}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-align-panel
|
||||
.value=${selectedAlign}
|
||||
.onSelect=${this._setTextAlign}
|
||||
></edgeless-align-panel>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
].filter(b => b !== nothing),
|
||||
renderToolbarSeparator
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor elements!: SurfaceTextModel[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor elementType!: keyof SurfaceTextModelMap;
|
||||
|
||||
@query('edgeless-color-picker-button.text-color')
|
||||
accessor textColorButton!: EdgelessColorPickerButton;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { EdgelessAddFrameButton } from './add-frame-button.js';
|
||||
import { EdgelessAddGroupButton } from './add-group-button.js';
|
||||
import { EdgelessAlignButton } from './align-button.js';
|
||||
import { EdgelessChangeAttachmentButton } from './change-attachment-button.js';
|
||||
import { EdgelessChangeBrushButton } from './change-brush-button.js';
|
||||
import { EdgelessChangeConnectorButton } from './change-connector-button.js';
|
||||
import { EdgelessChangeEmbedCardButton } from './change-embed-card-button.js';
|
||||
import { EdgelessChangeFrameButton } from './change-frame-button.js';
|
||||
import { EdgelessChangeGroupButton } from './change-group-button.js';
|
||||
import { EdgelessChangeImageButton } from './change-image-button.js';
|
||||
import {
|
||||
EdgelessChangeMindmapButton,
|
||||
EdgelessChangeMindmapLayoutPanel,
|
||||
EdgelessChangeMindmapStylePanel,
|
||||
} from './change-mindmap-button.js';
|
||||
import { EdgelessChangeNoteButton } from './change-note-button.js';
|
||||
import { EdgelessChangeShapeButton } from './change-shape-button.js';
|
||||
import { EdgelessChangeTextMenu } from './change-text-menu.js';
|
||||
import {
|
||||
EDGELESS_ELEMENT_TOOLBAR_WIDGET,
|
||||
EdgelessElementToolbarWidget,
|
||||
} from './index.js';
|
||||
import { EdgelessLockButton } from './lock-button.js';
|
||||
import { EdgelessMoreButton } from './more-menu/button.js';
|
||||
import { EdgelessReleaseFromGroupButton } from './release-from-group-button.js';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
EDGELESS_ELEMENT_TOOLBAR_WIDGET,
|
||||
EdgelessElementToolbarWidget
|
||||
);
|
||||
customElements.define('edgeless-add-frame-button', EdgelessAddFrameButton);
|
||||
customElements.define('edgeless-add-group-button', EdgelessAddGroupButton);
|
||||
customElements.define('edgeless-align-button', EdgelessAlignButton);
|
||||
customElements.define(
|
||||
'edgeless-change-attachment-button',
|
||||
EdgelessChangeAttachmentButton
|
||||
);
|
||||
customElements.define(
|
||||
'edgeless-change-brush-button',
|
||||
EdgelessChangeBrushButton
|
||||
);
|
||||
customElements.define(
|
||||
'edgeless-change-connector-button',
|
||||
EdgelessChangeConnectorButton
|
||||
);
|
||||
customElements.define(
|
||||
'edgeless-change-embed-card-button',
|
||||
EdgelessChangeEmbedCardButton
|
||||
);
|
||||
customElements.define(
|
||||
'edgeless-change-frame-button',
|
||||
EdgelessChangeFrameButton
|
||||
);
|
||||
customElements.define(
|
||||
'edgeless-change-group-button',
|
||||
EdgelessChangeGroupButton
|
||||
);
|
||||
customElements.define(
|
||||
'edgeless-change-image-button',
|
||||
EdgelessChangeImageButton
|
||||
);
|
||||
customElements.define(
|
||||
'edgeless-change-mindmap-style-panel',
|
||||
EdgelessChangeMindmapStylePanel
|
||||
);
|
||||
customElements.define(
|
||||
'edgeless-change-mindmap-layout-panel',
|
||||
EdgelessChangeMindmapLayoutPanel
|
||||
);
|
||||
customElements.define(
|
||||
'edgeless-change-mindmap-button',
|
||||
EdgelessChangeMindmapButton
|
||||
);
|
||||
customElements.define(
|
||||
'edgeless-change-note-button',
|
||||
EdgelessChangeNoteButton
|
||||
);
|
||||
customElements.define(
|
||||
'edgeless-change-shape-button',
|
||||
EdgelessChangeShapeButton
|
||||
);
|
||||
customElements.define('edgeless-change-text-menu', EdgelessChangeTextMenu);
|
||||
customElements.define(
|
||||
'edgeless-release-from-group-button',
|
||||
EdgelessReleaseFromGroupButton
|
||||
);
|
||||
customElements.define('edgeless-more-button', EdgelessMoreButton);
|
||||
customElements.define('edgeless-lock-button', EdgelessLockButton);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[EDGELESS_ELEMENT_TOOLBAR_WIDGET]: EdgelessElementToolbarWidget;
|
||||
'edgeless-add-frame-button': EdgelessAddFrameButton;
|
||||
'edgeless-add-group-button': EdgelessAddGroupButton;
|
||||
'edgeless-align-button': EdgelessAlignButton;
|
||||
'edgeless-change-attachment-button': EdgelessChangeAttachmentButton;
|
||||
'edgeless-change-brush-button': EdgelessChangeBrushButton;
|
||||
'edgeless-change-connector-button': EdgelessChangeConnectorButton;
|
||||
'edgeless-change-embed-card-button': EdgelessChangeEmbedCardButton;
|
||||
'edgeless-change-frame-button': EdgelessChangeFrameButton;
|
||||
'edgeless-change-group-button': EdgelessChangeGroupButton;
|
||||
'edgeless-change-mindmap-style-panel': EdgelessChangeMindmapStylePanel;
|
||||
'edgeless-change-mindmap-layout-panel': EdgelessChangeMindmapLayoutPanel;
|
||||
'edgeless-change-mindmap-button': EdgelessChangeMindmapButton;
|
||||
'edgeless-change-note-button': EdgelessChangeNoteButton;
|
||||
'edgeless-change-shape-button': EdgelessChangeShapeButton;
|
||||
'edgeless-change-text-menu': EdgelessChangeTextMenu;
|
||||
'edgeless-release-from-group-button': EdgelessReleaseFromGroupButton;
|
||||
'edgeless-more-button': EdgelessMoreButton;
|
||||
'edgeless-lock-button': EdgelessLockButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ArrowDownSmallIcon } from '@blocksuite/icons/lit';
|
||||
|
||||
export const SmallArrowDownIcon = ArrowDownSmallIcon({
|
||||
width: '16',
|
||||
height: '16',
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
import { isFrameBlock } from '@blocksuite/affine-block-frame';
|
||||
import { isNoteBlock } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
cloneGroups,
|
||||
darkToolbarStyles,
|
||||
getMoreMenuConfig,
|
||||
lightToolbarStyles,
|
||||
type MenuItemGroup,
|
||||
renderToolbarSeparator,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import type {
|
||||
AttachmentBlockModel,
|
||||
BrushElementModel,
|
||||
BuiltInEmbedModel,
|
||||
ConnectorElementModel,
|
||||
EdgelessTextBlockModel,
|
||||
FrameBlockModel,
|
||||
ImageBlockModel,
|
||||
MindmapElementModel,
|
||||
NoteBlockModel,
|
||||
RootBlockModel,
|
||||
TextElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
ConnectorMode,
|
||||
GroupElementModel,
|
||||
ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
||||
import { WidgetComponent } from '@blocksuite/block-std';
|
||||
import type { GfxModel } from '@blocksuite/block-std/gfx';
|
||||
import { clamp, getCommonBoundWithRotation } from '@blocksuite/global/gfx';
|
||||
import { ConnectorCIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html, nothing, type TemplateResult, unsafeCSS } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import groupBy from 'lodash-es/groupBy';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
import {
|
||||
isAttachmentBlock,
|
||||
isBookmarkBlock,
|
||||
isEdgelessTextBlock,
|
||||
isEmbeddedBlock,
|
||||
isImageBlock,
|
||||
} from '../../edgeless/utils/query.js';
|
||||
import { renderAddFrameButton } from './add-frame-button.js';
|
||||
import { renderAddGroupButton } from './add-group-button.js';
|
||||
import { renderAlignButton } from './align-button.js';
|
||||
import { renderAttachmentButton } from './change-attachment-button.js';
|
||||
import { renderChangeBrushButton } from './change-brush-button.js';
|
||||
import { renderConnectorButton } from './change-connector-button.js';
|
||||
import { renderChangeEdgelessTextButton } from './change-edgeless-text-button.js';
|
||||
import { renderEmbedButton } from './change-embed-card-button.js';
|
||||
import { renderFrameButton } from './change-frame-button.js';
|
||||
import { renderGroupButton } from './change-group-button.js';
|
||||
import { renderChangeImageButton } from './change-image-button.js';
|
||||
import { renderMindmapButton } from './change-mindmap-button.js';
|
||||
import { renderNoteButton } from './change-note-button.js';
|
||||
import { renderChangeShapeButton } from './change-shape-button.js';
|
||||
import { renderChangeTextButton } from './change-text-button.js';
|
||||
import { BUILT_IN_GROUPS } from './more-menu/config.js';
|
||||
import type { ElementToolbarMoreMenuContext } from './more-menu/context.js';
|
||||
import { renderReleaseFromGroupButton } from './release-from-group-button.js';
|
||||
|
||||
type CategorizedElements = {
|
||||
shape?: ShapeElementModel[];
|
||||
brush?: BrushElementModel[];
|
||||
text?: TextElementModel[];
|
||||
group?: GroupElementModel[];
|
||||
connector?: ConnectorElementModel[];
|
||||
note?: NoteBlockModel[];
|
||||
frame?: FrameBlockModel[];
|
||||
image?: ImageBlockModel[];
|
||||
attachment?: AttachmentBlockModel[];
|
||||
mindmap?: MindmapElementModel[];
|
||||
embedCard?: BuiltInEmbedModel[];
|
||||
edgelessText?: EdgelessTextBlockModel[];
|
||||
};
|
||||
|
||||
type CustomEntry = {
|
||||
render: (edgeless: EdgelessRootBlockComponent) => TemplateResult | null;
|
||||
when: (model: GfxModel[]) => boolean;
|
||||
};
|
||||
|
||||
export const EDGELESS_ELEMENT_TOOLBAR_WIDGET =
|
||||
'edgeless-element-toolbar-widget';
|
||||
|
||||
export class EdgelessElementToolbarWidget extends WidgetComponent<
|
||||
RootBlockModel,
|
||||
EdgelessRootBlockComponent
|
||||
> {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
editor-toolbar[data-app-theme='light'] {
|
||||
${unsafeCSS(lightToolbarStyles.join('\n'))}
|
||||
}
|
||||
editor-toolbar[data-app-theme='dark'] {
|
||||
${unsafeCSS(darkToolbarStyles.join('\n'))}
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _quickConnect = ({ x, y }: MouseEvent) => {
|
||||
const element = this.selection.selectedElements[0];
|
||||
const point = this.edgeless.service.viewport.toViewCoordFromClientCoord([
|
||||
x,
|
||||
y,
|
||||
]);
|
||||
this.edgeless.doc.captureSync();
|
||||
this.edgeless.gfx.tool.setTool('connector', {
|
||||
mode: ConnectorMode.Curve,
|
||||
});
|
||||
|
||||
const ctc = this.edgeless.gfx.tool.get('connector');
|
||||
ctc.quickConnect(point, element);
|
||||
};
|
||||
|
||||
private readonly _updateOnSelectedChange = (
|
||||
element: string | { id: string }
|
||||
) => {
|
||||
const id = typeof element === 'string' ? element : element.id;
|
||||
|
||||
if (this.isConnected && !this._dragging && this.selection.has(id)) {
|
||||
this._recalculatePosition();
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Caches the more menu items.
|
||||
* Currently only supports configuring more menu.
|
||||
*/
|
||||
moreGroups: MenuItemGroup<ElementToolbarMoreMenuContext>[] =
|
||||
cloneGroups(BUILT_IN_GROUPS);
|
||||
|
||||
get edgeless() {
|
||||
return this.block as EdgelessRootBlockComponent;
|
||||
}
|
||||
|
||||
get selection() {
|
||||
return this.edgeless.service.selection;
|
||||
}
|
||||
|
||||
get slots() {
|
||||
return this.edgeless.slots;
|
||||
}
|
||||
|
||||
get surface() {
|
||||
return this.edgeless.surface;
|
||||
}
|
||||
|
||||
private _groupSelected(): CategorizedElements {
|
||||
const result = groupBy(this.selection.selectedElements, model => {
|
||||
if (isNoteBlock(model)) {
|
||||
return 'note';
|
||||
} else if (isFrameBlock(model)) {
|
||||
return 'frame';
|
||||
} else if (isImageBlock(model)) {
|
||||
return 'image';
|
||||
} else if (isAttachmentBlock(model)) {
|
||||
return 'attachment';
|
||||
} else if (isBookmarkBlock(model) || isEmbeddedBlock(model)) {
|
||||
return 'embedCard';
|
||||
} else if (isEdgelessTextBlock(model)) {
|
||||
return 'edgelessText';
|
||||
}
|
||||
|
||||
return model.type;
|
||||
});
|
||||
return result as CategorizedElements;
|
||||
}
|
||||
|
||||
private _recalculatePosition() {
|
||||
const { selection, viewport } = this.edgeless.service;
|
||||
const elements = selection.selectedElements;
|
||||
|
||||
if (elements.length === 0) {
|
||||
this.style.transform = 'translate3d(0, 0, 0)';
|
||||
return;
|
||||
}
|
||||
|
||||
const bound = getCommonBoundWithRotation(elements);
|
||||
|
||||
const { width, height } = viewport;
|
||||
const { x, y, w } = viewport.toViewBound(bound);
|
||||
|
||||
let left = x;
|
||||
let top = y;
|
||||
|
||||
const hasLocked = elements.some(e => e.isLocked());
|
||||
|
||||
let offset = 37 + 12;
|
||||
// frame, group, shape
|
||||
let hasFrame = false;
|
||||
let hasGroup = false;
|
||||
if (
|
||||
(hasFrame = elements.some(ele => isFrameBlock(ele))) ||
|
||||
(hasGroup = elements.some(ele => ele instanceof GroupElementModel))
|
||||
) {
|
||||
offset += 16 + 4;
|
||||
if (hasFrame) {
|
||||
offset += 8;
|
||||
}
|
||||
} else if (
|
||||
elements.length === 1 &&
|
||||
elements[0] instanceof ShapeElementModel
|
||||
) {
|
||||
offset += 22 + 4;
|
||||
}
|
||||
|
||||
top = y - offset;
|
||||
if (top < 0) {
|
||||
top = y + bound.h * viewport.zoom + offset - 37;
|
||||
if (hasFrame || hasGroup) {
|
||||
top -= 16 + 4;
|
||||
if (hasFrame) {
|
||||
top -= 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestConnectedFrame(() => {
|
||||
const rect = this.getBoundingClientRect();
|
||||
|
||||
if (hasLocked) {
|
||||
left += 0.5 * (w - rect.width);
|
||||
}
|
||||
|
||||
left = clamp(left, 10, width - rect.width - 10);
|
||||
top = clamp(top, 10, height - rect.height - 150);
|
||||
|
||||
this.style.transform = `translate3d(${left}px, ${top}px, 0)`;
|
||||
}, this);
|
||||
}
|
||||
|
||||
private _renderButtons() {
|
||||
if (this.doc.readonly || this._dragging || !this.toolbarVisible) {
|
||||
return [];
|
||||
}
|
||||
const { selectedElements } = this.selection;
|
||||
if (selectedElements.some(e => e.isLocked())) {
|
||||
return [
|
||||
html`<edgeless-lock-button
|
||||
.edgeless=${this.edgeless}
|
||||
></edgeless-lock-button>`,
|
||||
];
|
||||
}
|
||||
|
||||
const groupedSelected = this._groupSelected();
|
||||
const { edgeless, selection } = this;
|
||||
const {
|
||||
shape,
|
||||
brush,
|
||||
connector,
|
||||
note,
|
||||
text,
|
||||
frame,
|
||||
group,
|
||||
embedCard,
|
||||
attachment,
|
||||
image,
|
||||
edgelessText,
|
||||
mindmap: mindmaps,
|
||||
} = groupedSelected;
|
||||
const selectedAtLeastTwoTypes =
|
||||
Object.values(groupedSelected).filter(e => !!e.length).length >= 2;
|
||||
|
||||
const quickConnectButton =
|
||||
selectedElements.length === 1 && !connector?.length
|
||||
? this._renderQuickConnectButton()
|
||||
: undefined;
|
||||
|
||||
const generalButtons =
|
||||
selectedElements.length !== connector?.length
|
||||
? [
|
||||
renderAddFrameButton(edgeless, selectedElements),
|
||||
renderAddGroupButton(edgeless, selectedElements),
|
||||
renderAlignButton(edgeless, selectedElements),
|
||||
]
|
||||
: [];
|
||||
|
||||
const buttons: (symbol | TemplateResult)[] = selectedAtLeastTwoTypes
|
||||
? generalButtons
|
||||
: [
|
||||
...generalButtons,
|
||||
renderMindmapButton(edgeless, mindmaps),
|
||||
renderMindmapButton(edgeless, shape),
|
||||
renderChangeShapeButton(edgeless, shape),
|
||||
renderChangeBrushButton(edgeless, brush),
|
||||
renderConnectorButton(edgeless, connector),
|
||||
renderNoteButton(edgeless, note, quickConnectButton),
|
||||
renderChangeTextButton(edgeless, text),
|
||||
renderChangeEdgelessTextButton(edgeless, edgelessText),
|
||||
renderFrameButton(edgeless, frame),
|
||||
renderGroupButton(edgeless, group),
|
||||
renderEmbedButton(edgeless, embedCard, quickConnectButton),
|
||||
renderAttachmentButton(edgeless, attachment),
|
||||
renderChangeImageButton(edgeless, image),
|
||||
];
|
||||
|
||||
if (selectedElements.length === 1) {
|
||||
if (selection.firstElement.group instanceof GroupElementModel) {
|
||||
buttons.unshift(renderReleaseFromGroupButton(this.edgeless));
|
||||
}
|
||||
|
||||
if (!connector?.length) {
|
||||
buttons.push(quickConnectButton?.pop() ?? nothing);
|
||||
}
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
html`<edgeless-lock-button
|
||||
.edgeless=${this.edgeless}
|
||||
></edgeless-lock-button>`
|
||||
);
|
||||
|
||||
this._registeredEntries
|
||||
.filter(entry => entry.when(selectedElements))
|
||||
.map(entry => entry.render(this.edgeless))
|
||||
.forEach(entry => entry && buttons.unshift(entry));
|
||||
|
||||
buttons.push(html`
|
||||
<edgeless-more-button
|
||||
.elements=${selectedElements}
|
||||
.edgeless=${edgeless}
|
||||
.groups=${this.moreGroups}
|
||||
.vertical=${true}
|
||||
></edgeless-more-button>
|
||||
`);
|
||||
|
||||
return buttons;
|
||||
}
|
||||
|
||||
private _renderQuickConnectButton() {
|
||||
return [
|
||||
html`
|
||||
<editor-icon-button
|
||||
aria-label="Draw connector"
|
||||
.tooltip=${'Draw connector'}
|
||||
.activeMode=${'background'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${this._quickConnect}
|
||||
>
|
||||
${ConnectorCIcon()}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
protected override firstUpdated() {
|
||||
const { _disposables, edgeless } = this;
|
||||
|
||||
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
|
||||
|
||||
_disposables.add(
|
||||
edgeless.service.viewport.viewportUpdated.on(() => {
|
||||
this._recalculatePosition();
|
||||
})
|
||||
);
|
||||
|
||||
_disposables.add(
|
||||
this.selection.slots.updated.on(() => {
|
||||
if (
|
||||
this.selection.selectedIds.length === 0 ||
|
||||
this.selection.editing ||
|
||||
this.selection.inoperable
|
||||
) {
|
||||
this.toolbarVisible = false;
|
||||
} else {
|
||||
this.selectedIds = this.selection.selectedIds;
|
||||
this._recalculatePosition();
|
||||
this.toolbarVisible = true;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
_disposables.add(
|
||||
this.edgeless.service.surface.elementAdded.on(
|
||||
this._updateOnSelectedChange
|
||||
)
|
||||
);
|
||||
_disposables.add(
|
||||
this.edgeless.service.surface.elementUpdated.on(
|
||||
this._updateOnSelectedChange
|
||||
)
|
||||
);
|
||||
|
||||
_disposables.add(
|
||||
this.doc.slots.blockUpdated.on(this._updateOnSelectedChange)
|
||||
);
|
||||
|
||||
_disposables.add(
|
||||
edgeless.dispatcher.add('dragStart', () => {
|
||||
this._dragging = true;
|
||||
})
|
||||
);
|
||||
_disposables.add(
|
||||
edgeless.dispatcher.add('dragEnd', () => {
|
||||
this._dragging = false;
|
||||
this._recalculatePosition();
|
||||
})
|
||||
);
|
||||
|
||||
_disposables.add(
|
||||
edgeless.slots.elementResizeStart.on(() => {
|
||||
this._dragging = true;
|
||||
})
|
||||
);
|
||||
_disposables.add(
|
||||
edgeless.slots.elementResizeEnd.on(() => {
|
||||
this._dragging = false;
|
||||
this._recalculatePosition();
|
||||
})
|
||||
);
|
||||
|
||||
_disposables.add(
|
||||
edgeless.slots.readonlyUpdated.on(() => this.requestUpdate())
|
||||
);
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
_disposables.add(
|
||||
this.std
|
||||
.get(ThemeProvider)
|
||||
.theme$.subscribe(() => this.requestUpdate())
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
registerEntry(entry: CustomEntry) {
|
||||
this._registeredEntries.push(entry);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const buttons = this._renderButtons();
|
||||
if (buttons.length === 0) return nothing;
|
||||
|
||||
const appTheme = this.std.get(ThemeProvider).app$.value;
|
||||
return html`
|
||||
<editor-toolbar data-app-theme=${appTheme}>
|
||||
${join(
|
||||
buttons.filter(b => b !== nothing),
|
||||
renderToolbarSeparator
|
||||
)}
|
||||
</editor-toolbar>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _dragging = false;
|
||||
|
||||
@state()
|
||||
private accessor _registeredEntries: {
|
||||
render: (edgeless: EdgelessRootBlockComponent) => TemplateResult | null;
|
||||
when: (model: GfxModel[]) => boolean;
|
||||
}[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor enableNoteSlicer!: boolean;
|
||||
|
||||
@state({
|
||||
hasChanged: (value: string[], oldValue: string[]) => {
|
||||
if (value.length !== oldValue?.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return value.some((id, index) => id !== oldValue[index]);
|
||||
},
|
||||
})
|
||||
accessor selectedIds: string[] = [];
|
||||
|
||||
@state()
|
||||
accessor toolbarVisible = false;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
GroupElementModel,
|
||||
MindmapElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
type ElementLockEvent,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import type { GfxModel } from '@blocksuite/block-std/gfx';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { LockIcon, UnlockIcon } from '@blocksuite/icons/lit';
|
||||
import { html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/index.js';
|
||||
|
||||
export class EdgelessLockButton extends SignalWatcher(
|
||||
WithDisposable(LitElement)
|
||||
) {
|
||||
private get _selectedElements() {
|
||||
const elements = new Set<GfxModel>();
|
||||
this.edgeless.service.selection.selectedElements.forEach(element => {
|
||||
if (element.group instanceof MindmapElementModel) {
|
||||
elements.add(element.group);
|
||||
} else {
|
||||
elements.add(element);
|
||||
}
|
||||
});
|
||||
return [...elements];
|
||||
}
|
||||
|
||||
private _lock() {
|
||||
const { service, doc, std } = this.edgeless;
|
||||
|
||||
// get most top selected elements(*) from tree, like in a tree below
|
||||
// G0
|
||||
// / \
|
||||
// E1* G1
|
||||
// / \
|
||||
// E2* E3*
|
||||
//
|
||||
// (*) selected elements, [E1, E2, E3]
|
||||
// return [E1]
|
||||
|
||||
const selectedElements = this._selectedElements;
|
||||
if (selectedElements.length === 0) return;
|
||||
|
||||
const levels = selectedElements.map(element => element.groups.length);
|
||||
const topElement = selectedElements[levels.indexOf(Math.min(...levels))];
|
||||
const otherElements = selectedElements.filter(
|
||||
element => element !== topElement
|
||||
);
|
||||
|
||||
doc.captureSync();
|
||||
|
||||
// release other elements from their groups and group with top element
|
||||
otherElements.forEach(element => {
|
||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
||||
element.group?.removeChild(element);
|
||||
topElement.group?.addChild(element);
|
||||
});
|
||||
|
||||
if (otherElements.length === 0) {
|
||||
topElement.lock();
|
||||
this.edgeless.gfx.selection.set({
|
||||
editing: false,
|
||||
elements: [topElement.id],
|
||||
});
|
||||
track(std, topElement, 'lock');
|
||||
return;
|
||||
}
|
||||
|
||||
const groupId = service.createGroup([topElement, ...otherElements]);
|
||||
|
||||
if (groupId) {
|
||||
const group = service.crud.getElementById(groupId);
|
||||
if (group) {
|
||||
group.lock();
|
||||
this.edgeless.gfx.selection.set({
|
||||
editing: false,
|
||||
elements: [groupId],
|
||||
});
|
||||
track(std, group, 'group-lock');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
selectedElements.forEach(e => {
|
||||
e.lock();
|
||||
track(std, e, 'lock');
|
||||
});
|
||||
|
||||
this.edgeless.gfx.selection.set({
|
||||
editing: false,
|
||||
elements: selectedElements.map(e => e.id),
|
||||
});
|
||||
}
|
||||
|
||||
private _unlock() {
|
||||
const { service, doc } = this.edgeless;
|
||||
|
||||
const selectedElements = this._selectedElements;
|
||||
if (selectedElements.length === 0) return;
|
||||
|
||||
doc.captureSync();
|
||||
|
||||
selectedElements.forEach(element => {
|
||||
if (element instanceof GroupElementModel) {
|
||||
service.ungroup(element);
|
||||
} else {
|
||||
element.lockedBySelf = false;
|
||||
}
|
||||
track(this.edgeless.std, element, 'unlock');
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const hasLocked = this._selectedElements.some(element =>
|
||||
element.isLocked()
|
||||
);
|
||||
|
||||
this.dataset.locked = hasLocked ? 'true' : 'false';
|
||||
|
||||
const icon = hasLocked ? UnlockIcon : LockIcon;
|
||||
|
||||
return html`<editor-icon-button
|
||||
@click=${hasLocked ? this._unlock : this._lock}
|
||||
>
|
||||
${icon({ width: '20px', height: '20px' })}
|
||||
${hasLocked
|
||||
? html`<span class="label medium">Click to unlock</span>`
|
||||
: nothing}
|
||||
</editor-icon-button>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
}
|
||||
|
||||
function track(
|
||||
std: BlockStdScope,
|
||||
element: GfxModel,
|
||||
control: ElementLockEvent['control']
|
||||
) {
|
||||
const type =
|
||||
'flavour' in element
|
||||
? (element.flavour.split(':')[1] ?? element.flavour)
|
||||
: element.type;
|
||||
|
||||
std.getOptional(TelemetryProvider)?.track('EdgelessElementLocked', {
|
||||
page: 'whiteboard editor',
|
||||
segment: 'element toolbar',
|
||||
module: 'element toolbar',
|
||||
control,
|
||||
type,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
|
||||
import { renderGroups } from '@blocksuite/affine-components/toolbar';
|
||||
import type { GfxModel } from '@blocksuite/block-std/gfx';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { MoreHorizontalIcon, MoreVerticalIcon } from '@blocksuite/icons/lit';
|
||||
import { html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../../edgeless/edgeless-root-block.js';
|
||||
import { ElementToolbarMoreMenuContext } from './context.js';
|
||||
|
||||
export class EdgelessMoreButton extends WithDisposable(LitElement) {
|
||||
override render() {
|
||||
const context = new ElementToolbarMoreMenuContext(this.edgeless);
|
||||
const actions = renderGroups(this.groups, context);
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="More" .tooltip=${'More'}>
|
||||
${this.vertical
|
||||
? MoreVerticalIcon({ width: '20', height: '20' })
|
||||
: MoreHorizontalIcon({ width: '20', height: '20' })}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div
|
||||
class="more-actions-container"
|
||||
data-size="large"
|
||||
data-orientation="vertical"
|
||||
>
|
||||
${actions}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor elements: GfxModel[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor groups!: MenuItemGroup<ElementToolbarMoreMenuContext>[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor vertical = false;
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import type { AttachmentBlockComponent } from '@blocksuite/affine-block-attachment';
|
||||
import type { BookmarkBlockComponent } from '@blocksuite/affine-block-bookmark';
|
||||
import {
|
||||
type EmbedFigmaBlockComponent,
|
||||
type EmbedGithubBlockComponent,
|
||||
type EmbedLoomBlockComponent,
|
||||
type EmbedYoutubeBlockComponent,
|
||||
notifyDocCreated,
|
||||
promptDocTitle,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import { isPeekable, peek } from '@blocksuite/affine-components/peek';
|
||||
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
OpenDocExtensionIdentifier,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { Bound, getCommonBoundWithRotation } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
ArrowDownBigBottomIcon,
|
||||
ArrowDownBigIcon,
|
||||
ArrowUpBigIcon,
|
||||
ArrowUpBigTopIcon,
|
||||
CenterPeekIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
ExpandFullIcon,
|
||||
FrameIcon,
|
||||
GroupIcon,
|
||||
LinkedPageIcon,
|
||||
OpenInNewIcon,
|
||||
ResetIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
|
||||
import { duplicate } from '../../../edgeless/utils/clipboard-utils.js';
|
||||
import { getSortedCloneElements } from '../../../edgeless/utils/clone-utils.js';
|
||||
import { moveConnectors } from '../../../edgeless/utils/connector.js';
|
||||
import { deleteElements } from '../../../edgeless/utils/crud.js';
|
||||
import type { ElementToolbarMoreMenuContext } from './context.js';
|
||||
import {
|
||||
createLinkedDocFromEdgelessElements,
|
||||
createLinkedDocFromNote,
|
||||
} from './render-linked-doc.js';
|
||||
|
||||
type EmbedLinkBlockComponent =
|
||||
| EmbedGithubBlockComponent
|
||||
| EmbedFigmaBlockComponent
|
||||
| EmbedLoomBlockComponent
|
||||
| EmbedYoutubeBlockComponent;
|
||||
|
||||
type RefreshableBlockComponent =
|
||||
| EmbedLinkBlockComponent
|
||||
| ImageBlockComponent
|
||||
| AttachmentBlockComponent
|
||||
| BookmarkBlockComponent;
|
||||
|
||||
// Section Group: frame & group
|
||||
export const sectionGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = {
|
||||
type: 'section',
|
||||
items: [
|
||||
{
|
||||
icon: FrameIcon({ width: '20', height: '20' }),
|
||||
label: 'Frame section',
|
||||
type: 'create-frame',
|
||||
action: ({ service, edgeless, std }) => {
|
||||
const frame = service.frame.createFrameOnSelected();
|
||||
if (!frame) return;
|
||||
|
||||
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'context-menu',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'frame',
|
||||
});
|
||||
|
||||
edgeless.surface.fitToViewport(Bound.deserialize(frame.xywh));
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: GroupIcon({ width: '20', height: '20' }),
|
||||
label: 'Group section',
|
||||
type: 'create-group',
|
||||
action: ({ service }) => {
|
||||
service.createGroupFromSelected();
|
||||
},
|
||||
when: ctx => !ctx.hasFrame(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Reorder Group
|
||||
export const reorderGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = {
|
||||
type: 'reorder',
|
||||
items: [
|
||||
{
|
||||
icon: ArrowUpBigTopIcon({ width: '20', height: '20' }),
|
||||
label: 'Bring to Front',
|
||||
type: 'front',
|
||||
action: ({ service, selectedElements }) => {
|
||||
selectedElements.forEach(el => {
|
||||
service.reorderElement(el, 'front');
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: ArrowUpBigIcon({ width: '20', height: '20' }),
|
||||
label: 'Bring Forward',
|
||||
type: 'forward',
|
||||
action: ({ service, selectedElements }) => {
|
||||
selectedElements.forEach(el => {
|
||||
service.reorderElement(el, 'forward');
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: ArrowDownBigIcon({ width: '20', height: '20' }),
|
||||
label: 'Send Backward',
|
||||
type: 'backward',
|
||||
action: ({ service, selectedElements }) => {
|
||||
selectedElements.forEach(el => {
|
||||
service.reorderElement(el, 'backward');
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: ArrowDownBigBottomIcon({ width: '20', height: '20' }),
|
||||
label: 'Send to Back',
|
||||
type: 'back',
|
||||
action: ({ service, selectedElements }) => {
|
||||
selectedElements.forEach(el => {
|
||||
service.reorderElement(el, 'back');
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Open Group
|
||||
// TODO: construct this group dynamically
|
||||
export const openGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = {
|
||||
type: 'open',
|
||||
items: [
|
||||
{
|
||||
icon: ExpandFullIcon({ width: '20', height: '20' }),
|
||||
label: 'Open this doc',
|
||||
type: 'open',
|
||||
generate: ctx => {
|
||||
const linkedDocBlock = ctx.getLinkedDocBlock();
|
||||
|
||||
if (!linkedDocBlock) return;
|
||||
|
||||
const disabled = linkedDocBlock.pageId === ctx.doc.id;
|
||||
|
||||
return {
|
||||
action: () => {
|
||||
const blockComponent = ctx.firstBlockComponent;
|
||||
|
||||
if (!blockComponent) return;
|
||||
if (!('open' in blockComponent)) return;
|
||||
if (typeof blockComponent.open !== 'function') return;
|
||||
|
||||
blockComponent.open();
|
||||
},
|
||||
|
||||
disabled,
|
||||
};
|
||||
},
|
||||
when: ctx => {
|
||||
const openDocService = ctx.std.get(OpenDocExtensionIdentifier);
|
||||
return openDocService.isAllowed('open-in-active-view');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: SplitViewIcon({ width: '20', height: '20' }),
|
||||
label: 'Open in split view',
|
||||
type: 'open-in-split-view',
|
||||
generate: ctx => {
|
||||
const linkedDocBlock = ctx.getLinkedDocBlock();
|
||||
|
||||
if (!linkedDocBlock) return;
|
||||
|
||||
return {
|
||||
action: () => {
|
||||
const blockComponent = ctx.firstBlockComponent;
|
||||
|
||||
if (!blockComponent) return;
|
||||
if (!('open' in blockComponent)) return;
|
||||
if (typeof blockComponent.open !== 'function') return;
|
||||
|
||||
blockComponent.open({ openMode: 'open-in-new-view' });
|
||||
},
|
||||
};
|
||||
},
|
||||
when: ctx => {
|
||||
const openDocService = ctx.std.get(OpenDocExtensionIdentifier);
|
||||
return openDocService.isAllowed('open-in-new-view');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: OpenInNewIcon({ width: '20', height: '20' }),
|
||||
label: 'Open in new tab',
|
||||
type: 'open-in-new-tab',
|
||||
generate: ctx => {
|
||||
const linkedDocBlock = ctx.getLinkedDocBlock();
|
||||
|
||||
if (!linkedDocBlock) return;
|
||||
|
||||
return {
|
||||
action: () => {
|
||||
const blockComponent = ctx.firstBlockComponent;
|
||||
|
||||
if (!blockComponent) return;
|
||||
if (!('open' in blockComponent)) return;
|
||||
if (typeof blockComponent.open !== 'function') return;
|
||||
|
||||
blockComponent.open({ openMode: 'open-in-new-tab' });
|
||||
},
|
||||
};
|
||||
},
|
||||
when: ctx => {
|
||||
const openDocService = ctx.std.get(OpenDocExtensionIdentifier);
|
||||
return openDocService.isAllowed('open-in-new-tab');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: CenterPeekIcon({ width: '20', height: '20' }),
|
||||
label: 'Open in center peek',
|
||||
type: 'center-peek',
|
||||
generate: ctx => {
|
||||
const valid =
|
||||
ctx.isSingle() &&
|
||||
!!ctx.firstBlockComponent &&
|
||||
isPeekable(ctx.firstBlockComponent);
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
return {
|
||||
action: () => {
|
||||
if (!ctx.firstBlockComponent) return;
|
||||
|
||||
peek(ctx.firstBlockComponent);
|
||||
},
|
||||
};
|
||||
},
|
||||
when: ctx => {
|
||||
const openDocService = ctx.std.get(OpenDocExtensionIdentifier);
|
||||
return openDocService.isAllowed('open-in-center-peek');
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Clipboard Group
|
||||
export const clipboardGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = {
|
||||
type: 'clipboard',
|
||||
items: [
|
||||
{
|
||||
icon: CopyIcon({ width: '20', height: '20' }),
|
||||
label: 'Copy',
|
||||
type: 'copy',
|
||||
action: ({ edgeless }) => edgeless.clipboardController.copy(),
|
||||
},
|
||||
{
|
||||
icon: DuplicateIcon({ width: '20', height: '20' }),
|
||||
label: 'Duplicate',
|
||||
type: 'duplicate',
|
||||
action: ({ edgeless, selectedElements }) =>
|
||||
duplicate(edgeless, selectedElements),
|
||||
},
|
||||
{
|
||||
icon: ResetIcon({ width: '20', height: '20' }),
|
||||
label: 'Reload',
|
||||
type: 'reload',
|
||||
generate: ctx => {
|
||||
if (ctx.hasFrame()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blocks = ctx.selection.surfaceSelections
|
||||
.map(s => ctx.getBlockComponent(s.blockId))
|
||||
.filter(block => !!block)
|
||||
.filter(block => ctx.refreshable(block.model));
|
||||
|
||||
if (
|
||||
!blocks.length ||
|
||||
blocks.length !== ctx.selection.surfaceSelections.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
action: () =>
|
||||
blocks.forEach(block =>
|
||||
(block as RefreshableBlockComponent).refreshData()
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Conversions Group
|
||||
export const conversionsGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = {
|
||||
type: 'conversions',
|
||||
items: [
|
||||
{
|
||||
icon: LinkedPageIcon({ width: '20', height: '20' }),
|
||||
label: 'Turn into linked doc',
|
||||
type: 'turn-into-linked-doc',
|
||||
action: async ctx => {
|
||||
const { doc, service, surface, std } = ctx;
|
||||
const element = ctx.getNoteBlock();
|
||||
if (!element) return;
|
||||
|
||||
const title = await promptDocTitle(std);
|
||||
if (title === null) return;
|
||||
|
||||
const linkedDoc = createLinkedDocFromNote(doc, element, title);
|
||||
const crud = std.get(EdgelessCRUDIdentifier);
|
||||
// insert linked doc card
|
||||
const cardId = crud.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
{
|
||||
xywh: element.xywh,
|
||||
style: 'syncedDoc',
|
||||
pageId: linkedDoc.id,
|
||||
index: element.index,
|
||||
},
|
||||
surface.model.id
|
||||
);
|
||||
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'context-menu',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'embed-synced-doc',
|
||||
});
|
||||
std.getOptional(TelemetryProvider)?.track('DocCreated', {
|
||||
control: 'turn into linked doc',
|
||||
page: 'whiteboard editor',
|
||||
module: 'format toolbar',
|
||||
type: 'embed-linked-doc',
|
||||
});
|
||||
std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
|
||||
control: 'turn into linked doc',
|
||||
page: 'whiteboard editor',
|
||||
module: 'format toolbar',
|
||||
type: 'embed-linked-doc',
|
||||
other: 'new doc',
|
||||
});
|
||||
moveConnectors(element.id, cardId, service);
|
||||
// delete selected note
|
||||
doc.transact(() => {
|
||||
doc.deleteBlock(element);
|
||||
});
|
||||
service.selection.set({
|
||||
elements: [cardId],
|
||||
editing: false,
|
||||
});
|
||||
},
|
||||
when: ctx => !!ctx.getNoteBlock(),
|
||||
},
|
||||
{
|
||||
icon: LinkedPageIcon({ width: '20', height: '20' }),
|
||||
label: 'Create linked doc',
|
||||
type: 'create-linked-doc',
|
||||
action: async ({ doc, selection, surface, edgeless, host, std }) => {
|
||||
const title = await promptDocTitle(std);
|
||||
if (title === null) return;
|
||||
|
||||
const elements = getSortedCloneElements(selection.selectedElements);
|
||||
const linkedDoc = createLinkedDocFromEdgelessElements(
|
||||
host,
|
||||
elements,
|
||||
title
|
||||
);
|
||||
const crud = std.get(EdgelessCRUDIdentifier);
|
||||
// delete selected elements
|
||||
doc.transact(() => {
|
||||
deleteElements(edgeless, elements);
|
||||
});
|
||||
// insert linked doc card
|
||||
const width = 364;
|
||||
const height = 390;
|
||||
const bound = getCommonBoundWithRotation(elements);
|
||||
const cardId = crud.addBlock(
|
||||
'affine:embed-linked-doc',
|
||||
{
|
||||
xywh: `[${bound.center[0] - width / 2}, ${bound.center[1] - height / 2}, ${width}, ${height}]`,
|
||||
style: 'vertical',
|
||||
pageId: linkedDoc.id,
|
||||
},
|
||||
surface.model.id
|
||||
);
|
||||
selection.set({
|
||||
elements: [cardId],
|
||||
editing: false,
|
||||
});
|
||||
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'context-menu',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'embed-linked-doc',
|
||||
});
|
||||
std.getOptional(TelemetryProvider)?.track('DocCreated', {
|
||||
control: 'create linked doc',
|
||||
page: 'whiteboard editor',
|
||||
module: 'format toolbar',
|
||||
type: 'embed-linked-doc',
|
||||
});
|
||||
std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
|
||||
control: 'create linked doc',
|
||||
page: 'whiteboard editor',
|
||||
module: 'format toolbar',
|
||||
type: 'embed-linked-doc',
|
||||
other: 'new doc',
|
||||
});
|
||||
|
||||
notifyDocCreated(std, doc);
|
||||
},
|
||||
when: ctx => !(ctx.getLinkedDocBlock() || ctx.getNoteBlock()),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Delete Group
|
||||
export const deleteGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = {
|
||||
type: 'delete',
|
||||
items: [
|
||||
{
|
||||
icon: DeleteIcon({ width: '20', height: '20' }),
|
||||
label: 'Delete',
|
||||
type: 'delete',
|
||||
action: ({ doc, selection, selectedElements, edgeless }) => {
|
||||
doc.captureSync();
|
||||
deleteElements(edgeless, selectedElements);
|
||||
|
||||
selection.set({
|
||||
elements: [],
|
||||
editing: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const BUILT_IN_GROUPS = [
|
||||
sectionGroup,
|
||||
reorderGroup,
|
||||
openGroup,
|
||||
clipboardGroup,
|
||||
conversionsGroup,
|
||||
deleteGroup,
|
||||
];
|
||||
@@ -0,0 +1,152 @@
|
||||
import { isFrameBlock } from '@blocksuite/affine-block-frame';
|
||||
import {
|
||||
isNoteBlock,
|
||||
type SurfaceBlockComponent,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { MenuContext } from '@blocksuite/affine-components/toolbar';
|
||||
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
|
||||
import {
|
||||
GfxPrimitiveElementModel,
|
||||
type GfxSelectionManager,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../../edgeless/edgeless-root-block.js';
|
||||
import type { EdgelessRootService } from '../../../edgeless/edgeless-root-service.js';
|
||||
import {
|
||||
isAttachmentBlock,
|
||||
isBookmarkBlock,
|
||||
isEmbeddedLinkBlock,
|
||||
isEmbedLinkedDocBlock,
|
||||
isEmbedSyncedDocBlock,
|
||||
isImageBlock,
|
||||
} from '../../../edgeless/utils/query.js';
|
||||
|
||||
export class ElementToolbarMoreMenuContext extends MenuContext {
|
||||
readonly #empty: boolean;
|
||||
|
||||
readonly #includedFrame: boolean;
|
||||
|
||||
readonly #multiple: boolean;
|
||||
|
||||
readonly #single: boolean;
|
||||
|
||||
edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
get doc() {
|
||||
return this.edgeless.doc;
|
||||
}
|
||||
|
||||
get firstBlockComponent() {
|
||||
return this.getBlockComponent(this.firstElement.id);
|
||||
}
|
||||
|
||||
override get firstElement() {
|
||||
return this.selection.firstElement;
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.edgeless.host;
|
||||
}
|
||||
|
||||
get selectedBlockModels() {
|
||||
const [result, { selectedModels }] = this.std.command.exec(
|
||||
getSelectedModelsCommand
|
||||
);
|
||||
|
||||
if (!result) return [];
|
||||
|
||||
return selectedModels ?? [];
|
||||
}
|
||||
|
||||
get selectedElements() {
|
||||
return this.selection.selectedElements;
|
||||
}
|
||||
|
||||
get selection(): GfxSelectionManager {
|
||||
return this.service.selection;
|
||||
}
|
||||
|
||||
get service(): EdgelessRootService {
|
||||
return this.edgeless.service;
|
||||
}
|
||||
|
||||
get std() {
|
||||
return this.edgeless.host.std;
|
||||
}
|
||||
|
||||
get surface(): SurfaceBlockComponent {
|
||||
return this.edgeless.surface;
|
||||
}
|
||||
|
||||
get view() {
|
||||
return this.host.view;
|
||||
}
|
||||
|
||||
constructor(edgeless: EdgelessRootBlockComponent) {
|
||||
super();
|
||||
this.edgeless = edgeless;
|
||||
|
||||
const selectedElements = this.selection.selectedElements;
|
||||
const len = selectedElements.length;
|
||||
|
||||
this.#empty = len === 0;
|
||||
this.#single = len === 1;
|
||||
this.#multiple = !this.#empty && !this.#single;
|
||||
this.#includedFrame = !this.#empty && selectedElements.some(isFrameBlock);
|
||||
}
|
||||
|
||||
getBlockComponent(id: string) {
|
||||
return this.view.getBlock(id);
|
||||
}
|
||||
|
||||
getLinkedDocBlock() {
|
||||
const valid =
|
||||
this.#single &&
|
||||
(isEmbedLinkedDocBlock(this.firstElement) ||
|
||||
isEmbedSyncedDocBlock(this.firstElement));
|
||||
|
||||
if (!valid) return null;
|
||||
|
||||
return this.firstElement;
|
||||
}
|
||||
|
||||
getNoteBlock() {
|
||||
const valid = this.#single && isNoteBlock(this.firstElement);
|
||||
|
||||
if (!valid) return null;
|
||||
|
||||
return this.firstElement;
|
||||
}
|
||||
|
||||
hasFrame() {
|
||||
return this.#includedFrame;
|
||||
}
|
||||
|
||||
override isElement() {
|
||||
return (
|
||||
this.#single && this.firstElement instanceof GfxPrimitiveElementModel
|
||||
);
|
||||
}
|
||||
|
||||
override isEmpty() {
|
||||
return this.#empty;
|
||||
}
|
||||
|
||||
isMultiple() {
|
||||
return this.#multiple;
|
||||
}
|
||||
|
||||
isSingle() {
|
||||
return this.#single;
|
||||
}
|
||||
|
||||
refreshable(model: BlockModel) {
|
||||
return (
|
||||
isImageBlock(model) ||
|
||||
isBookmarkBlock(model) ||
|
||||
isAttachmentBlock(model) ||
|
||||
isEmbeddedLinkBlock(model)
|
||||
);
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { isFrameBlock } from '@blocksuite/affine-block-frame';
|
||||
import { getSurfaceBlock, isNoteBlock } from '@blocksuite/affine-block-surface';
|
||||
import type { FrameBlockModel, NoteBlockModel } from '@blocksuite/affine-model';
|
||||
import { NoteDisplayMode } from '@blocksuite/affine-model';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { getBlockProps } from '@blocksuite/affine-shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { GfxBlockElementModel, type GfxModel } from '@blocksuite/block-std/gfx';
|
||||
import { type BlockModel, type Store, Text } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
getElementProps,
|
||||
mapFrameIds,
|
||||
sortEdgelessElements,
|
||||
} from '../../../edgeless/utils/clone-utils.js';
|
||||
|
||||
function addBlocksToDoc(targetDoc: Store, model: BlockModel, parentId: string) {
|
||||
// Add current block to linked doc
|
||||
const blockProps = getBlockProps(model);
|
||||
const newModelId = targetDoc.addBlock(model.flavour, blockProps, parentId);
|
||||
// Add children to linked doc, parent is the new model
|
||||
const children = model.children;
|
||||
if (children.length > 0) {
|
||||
children.forEach(child => {
|
||||
addBlocksToDoc(targetDoc, child, newModelId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createLinkedDocFromNote(
|
||||
doc: Store,
|
||||
note: NoteBlockModel,
|
||||
docTitle?: string
|
||||
) {
|
||||
const linkedDoc = doc.workspace.createDoc({});
|
||||
linkedDoc.load(() => {
|
||||
const rootId = linkedDoc.addBlock('affine:page', {
|
||||
title: new Text(docTitle),
|
||||
});
|
||||
linkedDoc.addBlock('affine:surface', {}, rootId);
|
||||
const blockProps = getBlockProps(note);
|
||||
// keep note props & show in both mode
|
||||
const noteId = linkedDoc.addBlock(
|
||||
'affine:note',
|
||||
{
|
||||
...blockProps,
|
||||
hidden: false,
|
||||
displayMode: NoteDisplayMode.DocAndEdgeless,
|
||||
},
|
||||
rootId
|
||||
);
|
||||
// Add note to linked doc recursively
|
||||
note.children.forEach(model => {
|
||||
addBlocksToDoc(linkedDoc, model, noteId);
|
||||
});
|
||||
});
|
||||
|
||||
return linkedDoc;
|
||||
}
|
||||
|
||||
export function createLinkedDocFromEdgelessElements(
|
||||
host: EditorHost,
|
||||
elements: GfxModel[],
|
||||
docTitle?: string
|
||||
) {
|
||||
const linkedDoc = host.doc.workspace.createDoc({});
|
||||
linkedDoc.load(() => {
|
||||
const rootId = linkedDoc.addBlock('affine:page', {
|
||||
title: new Text(docTitle),
|
||||
});
|
||||
const surfaceId = linkedDoc.addBlock('affine:surface', {}, rootId);
|
||||
const surface = getSurfaceBlock(linkedDoc);
|
||||
if (!surface) return;
|
||||
|
||||
const sortedElements = sortEdgelessElements(elements);
|
||||
const ids = new Map<string, string>();
|
||||
sortedElements.forEach(model => {
|
||||
let newId = model.id;
|
||||
if (model instanceof GfxBlockElementModel) {
|
||||
const blockProps = getBlockProps(model);
|
||||
if (isNoteBlock(model)) {
|
||||
newId = linkedDoc.addBlock('affine:note', blockProps, rootId);
|
||||
// Add note children to linked doc recursively
|
||||
model.children.forEach(model => {
|
||||
addBlocksToDoc(linkedDoc, model, newId);
|
||||
});
|
||||
} else {
|
||||
if (isFrameBlock(model)) {
|
||||
mapFrameIds(blockProps as unknown as FrameBlockModel, ids);
|
||||
}
|
||||
|
||||
newId = linkedDoc.addBlock(model.flavour, blockProps, surfaceId);
|
||||
}
|
||||
} else {
|
||||
const props = getElementProps(model, ids);
|
||||
newId = surface.addElement(props);
|
||||
}
|
||||
ids.set(model.id, newId);
|
||||
});
|
||||
});
|
||||
|
||||
host.std.get(DocModeProvider).setPrimaryMode('edgeless', linkedDoc.id);
|
||||
return linkedDoc;
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ReleaseFromGroupIcon } from '@blocksuite/icons/lit';
|
||||
import { html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js';
|
||||
|
||||
export class EdgelessReleaseFromGroupButton extends WithDisposable(LitElement) {
|
||||
private _releaseFromGroup() {
|
||||
const service = this.edgeless.service;
|
||||
const element = service.selection.firstElement;
|
||||
|
||||
if (!(element.group instanceof GroupElementModel)) return;
|
||||
|
||||
const group = element.group;
|
||||
|
||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
||||
group.removeChild(element);
|
||||
|
||||
element.index = service.layer.generateIndex();
|
||||
|
||||
const parent = group.group;
|
||||
if (parent instanceof GroupElementModel) {
|
||||
parent.addChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
return html`
|
||||
<editor-icon-button
|
||||
aria-label="Release from group"
|
||||
.tooltip=${'Release from group'}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() => this._releaseFromGroup()}
|
||||
>
|
||||
${ReleaseFromGroupIcon()}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
}
|
||||
|
||||
export function renderReleaseFromGroupButton(
|
||||
edgeless: EdgelessRootBlockComponent
|
||||
) {
|
||||
return html`
|
||||
<edgeless-release-from-group-button
|
||||
.edgeless=${edgeless}
|
||||
></edgeless-release-from-group-button>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const viewInPageNotifyFooter = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const viewInPageNotifyFooterButton = style({
|
||||
padding: '0px 6px',
|
||||
borderRadius: '4px',
|
||||
color: cssVarV2('text/primary'),
|
||||
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: '22px',
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
|
||||
':hover': {
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
});
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { createLitPortal } from '@blocksuite/affine-components/portal';
|
||||
import type {
|
||||
EditorIconButton,
|
||||
MenuItemGroup,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import { renderGroups } from '@blocksuite/affine-components/toolbar';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
|
||||
import { flip, offset } from '@floating-ui/dom';
|
||||
import { html, LitElement } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { ImageToolbarContext } from '../context.js';
|
||||
import { styles } from '../styles.js';
|
||||
|
||||
export class AffineImageToolbar extends LitElement {
|
||||
static override styles = styles;
|
||||
|
||||
private _currentOpenMenu: AbortController | null = null;
|
||||
|
||||
private _popMenuAbortController: AbortController | null = null;
|
||||
|
||||
closeCurrentMenu = () => {
|
||||
if (this._currentOpenMenu && !this._currentOpenMenu.signal.aborted) {
|
||||
this._currentOpenMenu.abort();
|
||||
this._currentOpenMenu = null;
|
||||
}
|
||||
};
|
||||
|
||||
private _clearPopMenu() {
|
||||
if (this._popMenuAbortController) {
|
||||
this._popMenuAbortController.abort();
|
||||
this._popMenuAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleMoreMenu() {
|
||||
// If the menu we're trying to open is already open, return
|
||||
if (
|
||||
this._currentOpenMenu &&
|
||||
!this._currentOpenMenu.signal.aborted &&
|
||||
this._currentOpenMenu === this._popMenuAbortController
|
||||
) {
|
||||
this.closeCurrentMenu();
|
||||
this._moreMenuOpen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeCurrentMenu();
|
||||
this._popMenuAbortController = new AbortController();
|
||||
this._popMenuAbortController.signal.addEventListener('abort', () => {
|
||||
this._moreMenuOpen = false;
|
||||
this.onActiveStatusChange(false);
|
||||
});
|
||||
this.onActiveStatusChange(true);
|
||||
|
||||
this._currentOpenMenu = this._popMenuAbortController;
|
||||
|
||||
if (!this._moreButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
createLitPortal({
|
||||
template: html`
|
||||
<editor-menu-content
|
||||
data-show
|
||||
class="image-more-popup-menu"
|
||||
style=${styleMap({
|
||||
'--content-padding': '8px',
|
||||
'--packed-height': '4px',
|
||||
})}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${renderGroups(this.moreGroups, this.context)}
|
||||
</div>
|
||||
</editor-menu-content>
|
||||
`,
|
||||
container: this.context.host,
|
||||
// stacking-context(editor-host)
|
||||
portalStyles: {
|
||||
zIndex: 'var(--affine-z-index-popover)',
|
||||
},
|
||||
computePosition: {
|
||||
referenceElement: this._moreButton,
|
||||
placement: 'bottom-start',
|
||||
middleware: [flip(), offset(4)],
|
||||
autoUpdate: { animationFrame: true },
|
||||
},
|
||||
abortController: this._popMenuAbortController,
|
||||
closeOnClickAway: true,
|
||||
});
|
||||
this._moreMenuOpen = true;
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.closeCurrentMenu();
|
||||
this._clearPopMenu();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<editor-toolbar class="affine-image-toolbar-container" data-without-bg>
|
||||
${renderGroups(this.primaryGroups, this.context)}
|
||||
<editor-icon-button
|
||||
class="image-toolbar-button more"
|
||||
aria-label="More"
|
||||
.tooltip=${'More'}
|
||||
.tooltipOffset=${4}
|
||||
.showTooltip=${!this._moreMenuOpen}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() => this._toggleMoreMenu()}
|
||||
>
|
||||
${MoreVerticalIcon()}
|
||||
</editor-icon-button>
|
||||
</editor-toolbar>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('editor-icon-button.more')
|
||||
private accessor _moreButton!: EditorIconButton;
|
||||
|
||||
@state()
|
||||
private accessor _moreMenuOpen = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: ImageToolbarContext;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor moreGroups!: MenuItemGroup<ImageToolbarContext>[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onActiveStatusChange: (active: boolean) => void = noop;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor primaryGroups!: MenuItemGroup<ImageToolbarContext>[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-image-toolbar': AffineImageToolbar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
CaptionIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DownloadIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
|
||||
import { BookmarkIcon, DuplicateIcon } from '@blocksuite/icons/lit';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
|
||||
import type { ImageToolbarContext } from './context.js';
|
||||
import { duplicate } from './utils.js';
|
||||
|
||||
export const PRIMARY_GROUPS: MenuItemGroup<ImageToolbarContext>[] = [
|
||||
{
|
||||
type: 'primary',
|
||||
items: [
|
||||
{
|
||||
type: 'download',
|
||||
label: 'Download',
|
||||
icon: DownloadIcon,
|
||||
generate: ({ blockComponent }) => {
|
||||
return {
|
||||
action: () => {
|
||||
blockComponent.download();
|
||||
},
|
||||
render: item => html`
|
||||
<editor-icon-button
|
||||
class="image-toolbar-button download"
|
||||
aria-label=${ifDefined(item.label)}
|
||||
.tooltip=${item.label}
|
||||
.tooltipOffset=${4}
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
>
|
||||
${item.icon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'caption',
|
||||
label: 'Caption',
|
||||
icon: CaptionIcon,
|
||||
when: ({ doc }) => !doc.readonly,
|
||||
generate: ({ blockComponent }) => {
|
||||
return {
|
||||
action: () => {
|
||||
blockComponent.captionEditor?.show();
|
||||
},
|
||||
render: item => html`
|
||||
<editor-icon-button
|
||||
class="image-toolbar-button caption"
|
||||
aria-label=${ifDefined(item.label)}
|
||||
.tooltip=${item.label}
|
||||
.tooltipOffset=${4}
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
>
|
||||
${item.icon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Clipboard Group
|
||||
export const clipboardGroup: MenuItemGroup<ImageToolbarContext> = {
|
||||
type: 'clipboard',
|
||||
items: [
|
||||
{
|
||||
type: 'copy',
|
||||
label: 'Copy',
|
||||
icon: CopyIcon,
|
||||
action: ({ blockComponent, close }) => {
|
||||
blockComponent.copy();
|
||||
close();
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: DuplicateIcon(),
|
||||
when: ({ doc }) => !doc.readonly,
|
||||
action: ({ blockComponent, abortController }) => {
|
||||
duplicate(blockComponent, abortController);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Conversions Group
|
||||
export const conversionsGroup: MenuItemGroup<ImageToolbarContext> = {
|
||||
type: 'conversions',
|
||||
items: [
|
||||
{
|
||||
label: 'Turn into card view',
|
||||
type: 'turn-into-card-view',
|
||||
icon: BookmarkIcon(),
|
||||
when: ({ doc, blockComponent }) => {
|
||||
const supportAttachment =
|
||||
doc.schema.flavourSchemaMap.has('affine:attachment');
|
||||
const readonly = doc.readonly;
|
||||
return supportAttachment && !readonly && !!blockComponent.blob;
|
||||
},
|
||||
action: ({ blockComponent, close }) => {
|
||||
blockComponent.convertToCardView();
|
||||
close();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Delete Group
|
||||
export const deleteGroup: MenuItemGroup<ImageToolbarContext> = {
|
||||
type: 'delete',
|
||||
items: [
|
||||
{
|
||||
type: 'delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon,
|
||||
when: ({ doc }) => !doc.readonly,
|
||||
action: ({ doc, blockComponent, close }) => {
|
||||
doc.deleteBlock(blockComponent.model);
|
||||
close();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const MORE_GROUPS: MenuItemGroup<ImageToolbarContext>[] = [
|
||||
clipboardGroup,
|
||||
conversionsGroup,
|
||||
deleteGroup,
|
||||
];
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
|
||||
import { MenuContext } from '@blocksuite/affine-components/toolbar';
|
||||
|
||||
export class ImageToolbarContext extends MenuContext {
|
||||
override close = () => {
|
||||
this.abortController.abort();
|
||||
};
|
||||
|
||||
get doc() {
|
||||
return this.blockComponent.doc;
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.blockComponent.host;
|
||||
}
|
||||
|
||||
get selectedBlockModels() {
|
||||
return [this.blockComponent.model];
|
||||
}
|
||||
|
||||
get std() {
|
||||
return this.blockComponent.std;
|
||||
}
|
||||
|
||||
constructor(
|
||||
public blockComponent: ImageBlockComponent,
|
||||
public abortController: AbortController
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isMultiple() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isSingle() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
|
||||
import { HoverController } from '@blocksuite/affine-components/hover';
|
||||
import type {
|
||||
AdvancedMenuItem,
|
||||
MenuItemGroup,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
cloneGroups,
|
||||
getMoreMenuConfig,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import type { ImageBlockModel } from '@blocksuite/affine-model';
|
||||
import { PAGE_HEADER_HEIGHT } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
BlockSelection,
|
||||
TextSelection,
|
||||
WidgetComponent,
|
||||
} from '@blocksuite/block-std';
|
||||
import { limitShift, shift } from '@floating-ui/dom';
|
||||
import { html } from 'lit';
|
||||
|
||||
import { MORE_GROUPS, PRIMARY_GROUPS } from './config.js';
|
||||
import { ImageToolbarContext } from './context.js';
|
||||
|
||||
export const AFFINE_IMAGE_TOOLBAR_WIDGET = 'affine-image-toolbar-widget';
|
||||
|
||||
export class AffineImageToolbarWidget extends WidgetComponent<
|
||||
ImageBlockModel,
|
||||
ImageBlockComponent
|
||||
> {
|
||||
private _hoverController: HoverController | null = null;
|
||||
|
||||
private _isActivated = false;
|
||||
|
||||
private readonly _setHoverController = () => {
|
||||
this._hoverController = null;
|
||||
this._hoverController = new HoverController(
|
||||
this,
|
||||
({ abortController }) => {
|
||||
const imageBlock = this.block;
|
||||
const selection = this.host.selection;
|
||||
|
||||
const textSelection = selection.find(TextSelection);
|
||||
if (
|
||||
!!textSelection &&
|
||||
(!!textSelection.to || !!textSelection.from.length)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blockSelections = selection.filter(BlockSelection);
|
||||
if (
|
||||
blockSelections.length > 1 ||
|
||||
(blockSelections.length === 1 &&
|
||||
blockSelections[0].blockId !== imageBlock.blockId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageContainer =
|
||||
imageBlock.resizableImg ?? imageBlock.fallbackCard;
|
||||
if (!imageContainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const context = new ImageToolbarContext(imageBlock, abortController);
|
||||
|
||||
return {
|
||||
template: html`<affine-image-toolbar
|
||||
.context=${context}
|
||||
.primaryGroups=${this.primaryGroups}
|
||||
.moreGroups=${this.moreGroups}
|
||||
.onActiveStatusChange=${(active: boolean) => {
|
||||
this._isActivated = active;
|
||||
if (!active && !this._hoverController?.isHovering) {
|
||||
this._hoverController?.abort();
|
||||
}
|
||||
}}
|
||||
></affine-image-toolbar>`,
|
||||
container: this.block,
|
||||
// stacking-context(editor-host)
|
||||
portalStyles: {
|
||||
zIndex: 'var(--affine-z-index-popover)',
|
||||
},
|
||||
computePosition: {
|
||||
referenceElement: imageContainer,
|
||||
placement: 'right-start',
|
||||
middleware: [
|
||||
shift({
|
||||
crossAxis: true,
|
||||
padding: {
|
||||
top: PAGE_HEADER_HEIGHT + 12,
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
},
|
||||
limiter: limitShift(),
|
||||
}),
|
||||
],
|
||||
autoUpdate: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
{ allowMultiple: true }
|
||||
);
|
||||
|
||||
const imageBlock = this.block;
|
||||
this._hoverController.setReference(imageBlock);
|
||||
this._hoverController.onAbort = () => {
|
||||
// If the more menu is opened, don't close it.
|
||||
if (this._isActivated) return;
|
||||
this._hoverController?.abort();
|
||||
return;
|
||||
};
|
||||
};
|
||||
|
||||
addMoreItems = (
|
||||
items: AdvancedMenuItem<ImageToolbarContext>[],
|
||||
index?: number,
|
||||
type?: string
|
||||
) => {
|
||||
let group;
|
||||
if (type) {
|
||||
group = this.moreGroups.find(g => g.type === type);
|
||||
}
|
||||
if (!group) {
|
||||
group = this.moreGroups[0];
|
||||
}
|
||||
|
||||
if (index === undefined) {
|
||||
group.items.push(...items);
|
||||
return this;
|
||||
}
|
||||
|
||||
group.items.splice(index, 0, ...items);
|
||||
return this;
|
||||
};
|
||||
|
||||
addPrimaryItems = (
|
||||
items: AdvancedMenuItem<ImageToolbarContext>[],
|
||||
index?: number
|
||||
) => {
|
||||
if (index === undefined) {
|
||||
this.primaryGroups[0].items.push(...items);
|
||||
return this;
|
||||
}
|
||||
|
||||
this.primaryGroups[0].items.splice(index, 0, ...items);
|
||||
return this;
|
||||
};
|
||||
|
||||
/*
|
||||
* Caches the more menu items.
|
||||
* Currently only supports configuring more menu.
|
||||
*/
|
||||
moreGroups: MenuItemGroup<ImageToolbarContext>[] = cloneGroups(MORE_GROUPS);
|
||||
|
||||
primaryGroups: MenuItemGroup<ImageToolbarContext>[] =
|
||||
cloneGroups(PRIMARY_GROUPS);
|
||||
|
||||
override firstUpdated() {
|
||||
if (this.doc.getParent(this.model.id)?.flavour === 'affine:surface') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
|
||||
this._setHoverController();
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_IMAGE_TOOLBAR_WIDGET]: AffineImageToolbarWidget;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
|
||||
.affine-image-toolbar-container {
|
||||
height: 24px;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.image-toolbar-button {
|
||||
color: var(--affine-icon-color);
|
||||
background-color: var(--affine-background-primary-color);
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
|
||||
import {
|
||||
getBlockProps,
|
||||
isInsidePageEditor,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { BlockSelection } from '@blocksuite/block-std';
|
||||
|
||||
export function duplicate(
|
||||
block: ImageBlockComponent,
|
||||
abortController?: AbortController
|
||||
) {
|
||||
const model = block.model;
|
||||
const blockProps = getBlockProps(model);
|
||||
const {
|
||||
width: _width,
|
||||
height: _height,
|
||||
xywh: _xywh,
|
||||
rotate: _rotate,
|
||||
zIndex: _zIndex,
|
||||
...duplicateProps
|
||||
} = blockProps;
|
||||
|
||||
const { doc } = model;
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) {
|
||||
console.error(`Parent not found for block(${model.flavour}) ${model.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const index = parent?.children.indexOf(model);
|
||||
const duplicateId = doc.addBlock(
|
||||
model.flavour,
|
||||
duplicateProps,
|
||||
parent,
|
||||
index + 1
|
||||
);
|
||||
abortController?.abort();
|
||||
|
||||
const editorHost = block.host;
|
||||
editorHost.updateComplete
|
||||
.then(() => {
|
||||
const { selection } = editorHost;
|
||||
selection.setGroup('note', [
|
||||
selection.create(BlockSelection, {
|
||||
blockId: duplicateId,
|
||||
}),
|
||||
]);
|
||||
if (isInsidePageEditor(editorHost)) {
|
||||
const duplicateElement = editorHost.view.getBlock(duplicateId);
|
||||
if (duplicateElement) {
|
||||
duplicateElement.scrollIntoView(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export { EDGELESS_TOOLBAR_WIDGET } from '../edgeless/components/toolbar/edgeless-toolbar.js';
|
||||
export { AffineEdgelessZoomToolbarWidget } from './edgeless-zoom-toolbar/index.js';
|
||||
export {
|
||||
EDGELESS_ELEMENT_TOOLBAR_WIDGET,
|
||||
EdgelessElementToolbarWidget,
|
||||
} from './element-toolbar/index.js';
|
||||
export { AffineImageToolbarWidget } from './image-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 { AffineSurfaceRefToolbar } from './surface-ref-toolbar/surface-ref-toolbar.js';
|
||||
export * from './viewport-overlay/viewport-overlay.js';
|
||||
export { AffineFrameTitleWidget } from '@blocksuite/affine-widget-frame-title';
|
||||
@@ -0,0 +1,64 @@
|
||||
import { WidgetComponent } from '@blocksuite/block-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;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_INNER_MODAL_WIDGET]: AffineInnerModalWidget;
|
||||
}
|
||||
}
|
||||
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,99 @@
|
||||
import { getDocTitleByEditorHost } from '@blocksuite/affine-fragment-doc-title';
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import { WidgetComponent } from '@blocksuite/block-std';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { 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);
|
||||
} else if (document.activeElement === this.block.rootComponent) {
|
||||
this.std.selection.clear();
|
||||
}
|
||||
}
|
||||
this._show$.value = false;
|
||||
};
|
||||
|
||||
private readonly _show$ = signal(false);
|
||||
|
||||
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;
|
||||
if (rootComponent) {
|
||||
this.disposables.addFromEvent(rootComponent, 'focus', () => {
|
||||
this._show$.value = true;
|
||||
});
|
||||
this.disposables.addFromEvent(rootComponent, 'blur', () => {
|
||||
this._show$.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
.config=${this.config}
|
||||
.rootComponent=${this.block.rootComponent}
|
||||
.close=${this._close}
|
||||
></affine-keyboard-toolbar> `}
|
||||
></blocksuite-portal>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_KEYBOARD_TOOLBAR_WIDGET]: AffineKeyboardToolbarWidget;
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
PropTypes,
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
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({ type: Number })
|
||||
accessor height = 0;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
|
||||
import { VirtualKeyboardProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
PropTypes,
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ArrowLeftBigIcon, KeyboardIcon } from '@blocksuite/icons/lit';
|
||||
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 keyboard() {
|
||||
return this._context.std.get(VirtualKeyboardProvider);
|
||||
}
|
||||
|
||||
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 close: (blur: boolean) => void = () => {};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor config!: KeyboardToolbarConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor rootComponent!: PageRootBlockComponent;
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { type VirtualKeyboardProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { DisposableGroup } from '@blocksuite/global/slot';
|
||||
import { effect, type Signal } from '@preact/signals-core';
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
|
||||
import { TOOLBAR_HEIGHT } from './styles';
|
||||
|
||||
/**
|
||||
* 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, panelOpened } = this.host;
|
||||
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
if (keyboard.visible$.value) {
|
||||
this.host.panelHeight$.value = keyboard.height$.value;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.host.style.bottom = '0px';
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
if (keyboard.visible$.value) {
|
||||
document.body.style.paddingBottom = `${keyboard.height$.value + TOOLBAR_HEIGHT}px`;
|
||||
} else if (panelOpened) {
|
||||
document.body.style.paddingBottom = `${this.host.panelHeight$.peek() + TOOLBAR_HEIGHT}px`;
|
||||
} else {
|
||||
document.body.style.paddingBottom = '';
|
||||
}
|
||||
})
|
||||
);
|
||||
this._disposables.add(() => {
|
||||
document.body.style.paddingBottom = '';
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { css } from 'lit';
|
||||
|
||||
export const TOOLBAR_HEIGHT = 46;
|
||||
|
||||
export const keyboardToolbarStyles = css`
|
||||
affine-keyboard-toolbar {
|
||||
position: fixed;
|
||||
display: block;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.keyboard-toolbar {
|
||||
width: 100%;
|
||||
height: ${TOOLBAR_HEIGHT}px;
|
||||
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 {
|
||||
type AffineInlineEditor,
|
||||
insertLinkedNode,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
DocModeProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
createDefaultDoc,
|
||||
isFuzzyMatch,
|
||||
type Signal,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockStdScope, EditorHost } from '@blocksuite/block-std';
|
||||
import type { InlineRange } from '@blocksuite/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>;
|
||||
};
|
||||
|
||||
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/block-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 type { BlockComponent } from '@blocksuite/block-std';
|
||||
import { BLOCK_ID_ATTR, WidgetComponent } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import {
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineEditor,
|
||||
type InlineRootElement,
|
||||
} from '@blocksuite/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.on(() => {
|
||||
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.on(() => {
|
||||
this._updateInputRects();
|
||||
});
|
||||
this._context = {
|
||||
std: this.std,
|
||||
inlineEditor,
|
||||
startRange: inlineRange,
|
||||
triggerKey: primaryTriggerKey,
|
||||
config: this.config,
|
||||
close: () => {
|
||||
disposable.dispose();
|
||||
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,397 @@
|
||||
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 { PropTypes, requiredProperties } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
|
||||
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.host,
|
||||
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) {
|
||||
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,
|
||||
onInput: isComposition => {
|
||||
if (isComposition) {
|
||||
this._updateLinkedDocGroup().catch(console.error);
|
||||
} else {
|
||||
this.context.inlineEditor.slots.renderComplete.once(
|
||||
this._updateLinkedDocGroup
|
||||
);
|
||||
}
|
||||
},
|
||||
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();
|
||||
}
|
||||
this.context.inlineEditor.slots.renderComplete.once(
|
||||
this._updateLinkedDocGroup
|
||||
);
|
||||
},
|
||||
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 = 380;
|
||||
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
|
||||
const isLoading = resolveSignal(group.loading);
|
||||
return {
|
||||
...group,
|
||||
isLoading,
|
||||
};
|
||||
});
|
||||
|
||||
return html`<div class="linked-doc-popover" style="${style}">
|
||||
${actionGroups
|
||||
.filter(group => group.items.length || group.isLoading)
|
||||
.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="280px"
|
||||
height="30px"
|
||||
data-id=${key}
|
||||
.text=${name}
|
||||
hover=${this._activatedItemKey === key}
|
||||
@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.on(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,251 @@
|
||||
import {
|
||||
cleanSpecifiedTail,
|
||||
getTextContentFromInlineRange,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import { VirtualKeyboardProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
createKeydownObserver,
|
||||
getViewportElement,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { PropTypes, requiredProperties } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
|
||||
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.host,
|
||||
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 {
|
||||
inlineEditor.slots.renderComplete.once(this._updateLinkedDocGroup);
|
||||
}
|
||||
},
|
||||
onDelete: () => {
|
||||
inlineEditor.slots.renderComplete.once(() => {
|
||||
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() {
|
||||
if (!this.keyboard.visible$.value) {
|
||||
this.keyboard.show();
|
||||
}
|
||||
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/block-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;
|
||||
}
|
||||
}
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
import { NoteBlockModel, RootBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
autoScroll,
|
||||
getScrollContainer,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
BlockComponent,
|
||||
BlockSelection,
|
||||
type PointerEventState,
|
||||
WidgetComponent,
|
||||
} from '@blocksuite/block-std';
|
||||
import { html, nothing } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { PageRootBlockComponent } from '../../index.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,
|
||||
PageRootBlockComponent
|
||||
> {
|
||||
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) {
|
||||
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() {
|
||||
const rootComponent = this.block;
|
||||
if (!rootComponent) return;
|
||||
return rootComponent.viewport;
|
||||
}
|
||||
|
||||
private get scrollContainer() {
|
||||
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)) {
|
||||
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;
|
||||
}
|
||||
return;
|
||||
},
|
||||
{ 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,110 @@
|
||||
import {
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DownloadIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
|
||||
import { downloadBlob } from '@blocksuite/affine-shared/utils';
|
||||
|
||||
import type { EdgelessRootPreviewBlockComponent } from '../../edgeless/edgeless-root-preview-block.js';
|
||||
import type { SurfaceRefToolbarContext } from './context.js';
|
||||
import { edgelessToBlob, writeImageBlobToClipboard } from './utils.js';
|
||||
|
||||
export const BUILT_IN_GROUPS: MenuItemGroup<SurfaceRefToolbarContext>[] = [
|
||||
{
|
||||
type: 'clipboard',
|
||||
when: ctx => !!(ctx.blockComponent.referenceModel && ctx.doc.root),
|
||||
items: [
|
||||
{
|
||||
type: 'copy',
|
||||
label: 'Copy',
|
||||
icon: CopyIcon,
|
||||
action: ctx => {
|
||||
if (!(ctx.blockComponent.referenceModel && ctx.doc.root?.id)) {
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const referencedModel = ctx.blockComponent.referenceModel;
|
||||
const editor = ctx.blockComponent.previewEditor;
|
||||
const edgelessRootElement = editor?.view.getBlock(ctx.doc.root.id);
|
||||
const surfaceRenderer = (
|
||||
edgelessRootElement as EdgelessRootPreviewBlockComponent
|
||||
)?.surface?.renderer;
|
||||
|
||||
if (!surfaceRenderer) {
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
edgelessToBlob(ctx.host, {
|
||||
surfaceRefBlock: ctx.blockComponent,
|
||||
surfaceRenderer,
|
||||
edgelessElement: referencedModel,
|
||||
})
|
||||
.then(blob => writeImageBlobToClipboard(blob))
|
||||
.then(() => toast(ctx.host, 'Copied image to clipboard'))
|
||||
.catch(console.error);
|
||||
|
||||
ctx.close();
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'download',
|
||||
label: 'Download',
|
||||
icon: DownloadIcon,
|
||||
action: ctx => {
|
||||
if (!(ctx.blockComponent.referenceModel && ctx.doc.root?.id)) {
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const referencedModel = ctx.blockComponent.referenceModel;
|
||||
const editor = ctx.blockComponent.previewEditor;
|
||||
const edgelessRootElement = editor?.view.getBlock(ctx.doc.root.id);
|
||||
const surfaceRenderer = (
|
||||
edgelessRootElement as EdgelessRootPreviewBlockComponent
|
||||
)?.surface?.renderer;
|
||||
|
||||
if (!surfaceRenderer) {
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
edgelessToBlob(ctx.host, {
|
||||
surfaceRefBlock: ctx.blockComponent,
|
||||
surfaceRenderer,
|
||||
edgelessElement: referencedModel,
|
||||
})
|
||||
.then(blob => {
|
||||
const fileName =
|
||||
'title' in referencedModel
|
||||
? (referencedModel.title?.toString() ?? 'Edgeless Content')
|
||||
: 'Edgeless Content';
|
||||
|
||||
downloadBlob(blob, fileName);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
ctx.close();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
items: [
|
||||
{
|
||||
type: 'delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon,
|
||||
disabled: ({ doc }) => doc.readonly,
|
||||
action: ({ blockComponent, doc, close }) => {
|
||||
doc.deleteBlock(blockComponent.model);
|
||||
close();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SurfaceRefBlockComponent } from '@blocksuite/affine-block-surface-ref';
|
||||
import { MenuContext } from '@blocksuite/affine-components/toolbar';
|
||||
|
||||
export class SurfaceRefToolbarContext extends MenuContext {
|
||||
override close = () => {
|
||||
this.abortController.abort();
|
||||
};
|
||||
|
||||
get doc() {
|
||||
return this.blockComponent.doc;
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.blockComponent.host;
|
||||
}
|
||||
|
||||
get selectedBlockModels() {
|
||||
if (this.blockComponent) return [this.blockComponent.model];
|
||||
return [];
|
||||
}
|
||||
|
||||
get std() {
|
||||
return this.host.std;
|
||||
}
|
||||
|
||||
constructor(
|
||||
public blockComponent: SurfaceRefBlockComponent,
|
||||
public abortController: AbortController
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return !this.blockComponent;
|
||||
}
|
||||
|
||||
isMultiple() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isSingle() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
import type { SurfaceRefBlockComponent } from '@blocksuite/affine-block-surface-ref';
|
||||
import { HoverController } from '@blocksuite/affine-components/hover';
|
||||
import { CaptionIcon, OpenIcon } from '@blocksuite/affine-components/icons';
|
||||
import { isPeekable, peek } from '@blocksuite/affine-components/peek';
|
||||
import {
|
||||
cloneGroups,
|
||||
getMoreMenuConfig,
|
||||
type MenuItem,
|
||||
type MenuItemGroup,
|
||||
renderGroups,
|
||||
renderToolbarSeparator,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import type { SurfaceRefBlockModel } from '@blocksuite/affine-model';
|
||||
import { PAGE_HEADER_HEIGHT } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
BlockSelection,
|
||||
TextSelection,
|
||||
WidgetComponent,
|
||||
} from '@blocksuite/block-std';
|
||||
import {
|
||||
ArrowDownSmallIcon,
|
||||
CenterPeekIcon,
|
||||
EdgelessIcon,
|
||||
MoreVerticalIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { offset, shift } from '@floating-ui/dom';
|
||||
import { html, nothing } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { BUILT_IN_GROUPS } from './config.js';
|
||||
import { SurfaceRefToolbarContext } from './context.js';
|
||||
|
||||
export const AFFINE_SURFACE_REF_TOOLBAR = 'affine-surface-ref-toolbar';
|
||||
|
||||
export class AffineSurfaceRefToolbar extends WidgetComponent<
|
||||
SurfaceRefBlockModel,
|
||||
SurfaceRefBlockComponent
|
||||
> {
|
||||
/*
|
||||
* Caches the more menu items.
|
||||
* Currently only supports configuring more menu.
|
||||
*/
|
||||
moreGroups: MenuItemGroup<SurfaceRefToolbarContext>[] =
|
||||
cloneGroups(BUILT_IN_GROUPS);
|
||||
|
||||
private readonly _hoverController = new HoverController(
|
||||
this,
|
||||
({ abortController }) => {
|
||||
const surfaceRefBlock = this.block;
|
||||
const selection = this.host.selection;
|
||||
|
||||
const textSelection = selection.find(TextSelection);
|
||||
if (
|
||||
!!textSelection &&
|
||||
(!!textSelection.to || !!textSelection.from.length)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blockSelections = selection.filter(BlockSelection);
|
||||
if (
|
||||
blockSelections.length > 1 ||
|
||||
(blockSelections.length === 1 &&
|
||||
blockSelections[0].blockId !== surfaceRefBlock.blockId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
template: SurfaceRefToolbarOptions({
|
||||
context: new SurfaceRefToolbarContext(this.block, abortController),
|
||||
groups: this.moreGroups,
|
||||
}),
|
||||
computePosition: {
|
||||
referenceElement: this.block,
|
||||
placement: 'top-start',
|
||||
middleware: [
|
||||
offset({
|
||||
mainAxis: 12,
|
||||
crossAxis: 10,
|
||||
}),
|
||||
shift({
|
||||
crossAxis: true,
|
||||
padding: {
|
||||
top: PAGE_HEADER_HEIGHT + 12,
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
},
|
||||
}),
|
||||
],
|
||||
autoUpdate: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
|
||||
this._hoverController.setReference(this.block);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_SURFACE_REF_TOOLBAR]: AffineSurfaceRefToolbar;
|
||||
}
|
||||
}
|
||||
|
||||
function SurfaceRefToolbarOptions({
|
||||
context,
|
||||
groups,
|
||||
}: {
|
||||
context: SurfaceRefToolbarContext;
|
||||
groups: MenuItemGroup<SurfaceRefToolbarContext>[];
|
||||
}) {
|
||||
const { blockComponent, abortController } = context;
|
||||
const readonly = blockComponent.model.doc.readonly;
|
||||
const hasValidReference = !!blockComponent.referenceModel;
|
||||
|
||||
const openMenuActions: MenuItem[] = [];
|
||||
|
||||
const iconSize = { width: '20px', height: '20px' };
|
||||
if (hasValidReference) {
|
||||
openMenuActions.push({
|
||||
type: 'open-in-edgeless',
|
||||
label: 'Open in edgeless',
|
||||
icon: EdgelessIcon(iconSize),
|
||||
action: () => blockComponent.viewInEdgeless(),
|
||||
disabled: readonly,
|
||||
});
|
||||
|
||||
if (isPeekable(blockComponent)) {
|
||||
openMenuActions.push({
|
||||
type: 'open-in-center-peek',
|
||||
label: 'Open in center peek',
|
||||
icon: CenterPeekIcon(iconSize),
|
||||
action: () => peek(blockComponent),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const moreMenuActions = renderGroups(groups, context);
|
||||
|
||||
const buttons = [
|
||||
openMenuActions.length
|
||||
? html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Open doc"
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
>
|
||||
${OpenIcon}${ArrowDownSmallIcon({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
})}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${repeat(
|
||||
openMenuActions,
|
||||
button => button.label,
|
||||
({ label, icon, action, disabled }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${ifDefined(label)}
|
||||
?disabled=${disabled}
|
||||
@click=${action}
|
||||
>
|
||||
${icon}<span class="label">${label}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`
|
||||
: nothing,
|
||||
|
||||
readonly
|
||||
? nothing
|
||||
: html`
|
||||
<editor-icon-button
|
||||
aria-label="Caption"
|
||||
.tooltip=${'Add Caption'}
|
||||
@click=${() => {
|
||||
abortController.abort();
|
||||
blockComponent.captionElement.show();
|
||||
}}
|
||||
>
|
||||
${CaptionIcon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
|
||||
html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="More"
|
||||
.tooltip=${'More'}
|
||||
.iconSize=${'20px'}
|
||||
>
|
||||
${MoreVerticalIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${moreMenuActions}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
];
|
||||
|
||||
return html`
|
||||
<editor-toolbar class="surface-ref-toolbar-container">
|
||||
${join(
|
||||
buttons.filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
)}
|
||||
</editor-toolbar>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CanvasRenderer } from '@blocksuite/affine-block-surface';
|
||||
import { ExportManager } from '@blocksuite/affine-block-surface';
|
||||
import type { SurfaceRefBlockComponent } from '@blocksuite/affine-block-surface-ref';
|
||||
import { isTopLevelBlock } from '@blocksuite/affine-shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import {
|
||||
GfxControllerIdentifier,
|
||||
type GfxModel,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import { BlockSuiteError } from '@blocksuite/global/exceptions';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
|
||||
export const edgelessToBlob = async (
|
||||
host: EditorHost,
|
||||
options: {
|
||||
surfaceRefBlock: SurfaceRefBlockComponent;
|
||||
surfaceRenderer: CanvasRenderer;
|
||||
edgelessElement: GfxModel;
|
||||
}
|
||||
): Promise<Blob> => {
|
||||
const { edgelessElement } = options;
|
||||
const exportManager = host.std.get(ExportManager);
|
||||
const bound = Bound.deserialize(edgelessElement.xywh);
|
||||
const isBlock = isTopLevelBlock(edgelessElement);
|
||||
const gfx = host.std.get(GfxControllerIdentifier);
|
||||
|
||||
const canvas = await exportManager.edgelessToCanvas(
|
||||
options.surfaceRenderer,
|
||||
bound,
|
||||
gfx,
|
||||
isBlock ? [edgelessElement] : undefined,
|
||||
isBlock ? undefined : [edgelessElement],
|
||||
{ zoom: options.surfaceRenderer.viewport.zoom }
|
||||
);
|
||||
|
||||
if (!canvas) {
|
||||
throw new BlockSuiteError(
|
||||
BlockSuiteError.ErrorCode.ValueNotExists,
|
||||
'Failed to export edgeless to canvas'
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(blob => (blob ? resolve(blob) : reject(null)), 'image/png');
|
||||
});
|
||||
};
|
||||
|
||||
export const writeImageBlobToClipboard = async (blob: Blob) => {
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { WidgetComponent } from '@blocksuite/block-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';
|
||||
|
||||
import type { PageRootBlockComponent } from '../../index.js';
|
||||
|
||||
export const AFFINE_VIEWPORT_OVERLAY_WIDGET = 'affine-viewport-overlay-widget';
|
||||
|
||||
export class AffineViewportOverlayWidget extends WidgetComponent<
|
||||
RootBlockModel,
|
||||
PageRootBlockComponent
|
||||
> {
|
||||
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