mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08:00
refactor(editor): unify directories naming (#11516)
**Directory Structure Changes** - Renamed multiple block-related directories by removing the "block-" prefix: - `block-attachment` → `attachment` - `block-bookmark` → `bookmark` - `block-callout` → `callout` - `block-code` → `code` - `block-data-view` → `data-view` - `block-database` → `database` - `block-divider` → `divider` - `block-edgeless-text` → `edgeless-text` - `block-embed` → `embed`
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import type { Placement } from '@floating-ui/dom';
|
||||
import type { TemplateResult } from 'lit';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { cache } from 'lit/directives/cache.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export class EdgelessToolIconButton extends LitElement {
|
||||
static override styles = css`
|
||||
.icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--icon-container-padding);
|
||||
color: var(--affine-icon-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
width: var(--icon-container-width, unset);
|
||||
justify-content: var(--justify, unset);
|
||||
}
|
||||
|
||||
.icon-container.active-mode-color[active] {
|
||||
color: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.icon-container.active-mode-background[active] {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.icon-container[disabled] {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
color: var(--affine-text-disable-color);
|
||||
}
|
||||
|
||||
.icon-container[coming] {
|
||||
cursor: not-allowed;
|
||||
color: var(--affine-text-disable-color);
|
||||
}
|
||||
|
||||
::slotted(svg) {
|
||||
flex-shrink: 0;
|
||||
width: var(--icon-size, unset);
|
||||
height: var(--icon-size, unset);
|
||||
}
|
||||
|
||||
::slotted(.label) {
|
||||
flex: 1;
|
||||
padding: 0 4px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
line-height: var(--label-height, inherit);
|
||||
}
|
||||
::slotted(.label.padding0) {
|
||||
padding: 0;
|
||||
}
|
||||
::slotted(.label.ellipsis) {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
::slotted(.label.medium) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.icon-container[with-hover]::before {
|
||||
content: '';
|
||||
display: block;
|
||||
background: var(--affine-hover-color);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.addEventListener(
|
||||
'click',
|
||||
event => {
|
||||
if (this.disabled) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.role = 'button';
|
||||
}
|
||||
|
||||
override render() {
|
||||
const tooltip = this.coming ? '(Coming soon)' : this.tooltip;
|
||||
const classnames = `icon-container active-mode-${this.activeMode} ${this.hoverState ? 'hovered' : ''}`;
|
||||
const padding = this.iconContainerPadding;
|
||||
const iconContainerStyles = styleMap({
|
||||
'--icon-container-width': this.iconContainerWidth,
|
||||
'--icon-container-padding': Array.isArray(padding)
|
||||
? padding.map(v => `${v}px`).join(' ')
|
||||
: `${padding}px`,
|
||||
'--icon-size': this.iconSize,
|
||||
'--justify': this.justify,
|
||||
'--label-height': this.labelHeight,
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
.icon-container:hover,
|
||||
.icon-container.hovered {
|
||||
background: ${this.hover ? `var(--affine-hover-color)` : 'inherit'};
|
||||
}
|
||||
</style>
|
||||
<div
|
||||
class=${classnames}
|
||||
style=${iconContainerStyles}
|
||||
?with-hover=${this.withHover}
|
||||
?disabled=${this.disabled}
|
||||
?active=${this.active}
|
||||
>
|
||||
<slot></slot>
|
||||
${cache(
|
||||
this.showTooltip && tooltip
|
||||
? html`<affine-tooltip
|
||||
tip-position=${this.tipPosition}
|
||||
.arrow=${this.arrow}
|
||||
.offset=${this.tooltipOffset}
|
||||
>${tooltip}</affine-tooltip
|
||||
>`
|
||||
: nothing
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor active = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor activeMode: 'color' | 'background' = 'color';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor arrow = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor coming = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor disabled = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hover = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hoverState = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor iconContainerPadding: number | number[] = 2;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor iconContainerWidth: string | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor iconSize: string | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor justify: string | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor labelHeight: string | undefined = undefined;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor showTooltip = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor tipPosition: Placement = 'top';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor tooltip!: string | TemplateResult<1>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor tooltipOffset = 8;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor withHover: boolean | undefined = undefined;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { css, html } from 'lit';
|
||||
|
||||
import { EdgelessToolIconButton } from './tool-icon-button.js';
|
||||
|
||||
export class EdgelessToolbarButton extends EdgelessToolIconButton {
|
||||
static override styles = css`
|
||||
.icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
color: var(--affine-icon-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-container.active-mode-color[active] {
|
||||
color: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.icon-container.active-mode-background[active] {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.icon-container[disabled] {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon-container[coming] {
|
||||
cursor: not-allowed;
|
||||
color: var(--affine-text-disable-color);
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return html` ${super.render()} `;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StyleGeneralIcon, StyleScribbleIcon } from '@blocksuite/icons/lit';
|
||||
|
||||
import type { MenuItem } from './types';
|
||||
|
||||
export const LINE_STYLE_LIST = [
|
||||
{
|
||||
key: 'General',
|
||||
value: false,
|
||||
icon: StyleGeneralIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Scribbled',
|
||||
value: true,
|
||||
icon: StyleScribbleIcon(),
|
||||
},
|
||||
] as const satisfies MenuItem<boolean>[];
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './consts.js';
|
||||
export * from './types.js';
|
||||
export * from './utils.js';
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { MenuConfig } from '@blocksuite/affine-components/context-menu';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import type { GfxController } from '@blocksuite/std/gfx';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
export type MenuItem<T> = {
|
||||
key?: string;
|
||||
value: T;
|
||||
icon?: TemplateResult;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type Menu<T> = {
|
||||
label: string;
|
||||
icon?: TemplateResult;
|
||||
tooltip?: string;
|
||||
items: MenuItem<T>[];
|
||||
currentValue: T;
|
||||
onPick: (value: T) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to build a menu configuration for a tool in dense mode
|
||||
*/
|
||||
export type DenseMenuBuilder = (
|
||||
edgeless: BlockComponent,
|
||||
gfx: GfxController
|
||||
) => MenuConfig;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
import type { ToolbarContext } from '@blocksuite/affine-shared/services';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type { Menu, MenuItem } from './types';
|
||||
|
||||
export function renderCurrentMenuItemWith<T, F extends keyof MenuItem<T>>(
|
||||
items: MenuItem<T>[],
|
||||
currentValue: T,
|
||||
field: F
|
||||
) {
|
||||
return items.find(({ value }) => value === currentValue)?.[field];
|
||||
}
|
||||
|
||||
export function renderMenu<T>({
|
||||
label,
|
||||
tooltip,
|
||||
icon,
|
||||
items,
|
||||
currentValue,
|
||||
onPick,
|
||||
}: Menu<T>) {
|
||||
return html`
|
||||
<editor-menu-button
|
||||
aria-label="${`${label.toLowerCase()}-menu`}"
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="${label}"
|
||||
.tooltip="${tooltip ?? label}"
|
||||
>
|
||||
${icon ?? renderCurrentMenuItemWith(items, currentValue, 'icon')}
|
||||
${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
${renderMenuItems(items, currentValue, onPick)}
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderMenuItems<T>(
|
||||
items: MenuItem<T>[],
|
||||
currentValue: T,
|
||||
onPick: (value: T) => void
|
||||
) {
|
||||
return repeat(
|
||||
items,
|
||||
item => item.value,
|
||||
({ key, value, icon, disabled }) => html`
|
||||
<editor-icon-button
|
||||
aria-label="${ifDefined(key)}"
|
||||
.disabled=${ifDefined(disabled)}
|
||||
.tooltip="${ifDefined(key)}"
|
||||
.active="${currentValue === value}"
|
||||
.activeMode="${'background'}"
|
||||
@click=${() => onPick(value)}
|
||||
>
|
||||
${icon}
|
||||
</editor-icon-button>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
export function getRootBlock(ctx: ToolbarContext): BlockComponent | null {
|
||||
const rootModel = ctx.store.root;
|
||||
if (!rootModel) return null;
|
||||
|
||||
return ctx.view.getBlock(rootModel.id);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ColorScheme } from '@blocksuite/affine-model';
|
||||
import { createContext } from '@lit/context';
|
||||
import type { Subject } from 'rxjs';
|
||||
|
||||
import type { EdgelessToolbarWidget } from './edgeless-toolbar.js';
|
||||
|
||||
export interface EdgelessToolbarSlots {
|
||||
resize: Subject<{ w: number; h: number }>;
|
||||
}
|
||||
|
||||
export const edgelessToolbarSlotsContext = createContext<EdgelessToolbarSlots>(
|
||||
Symbol('edgelessToolbarSlotsContext')
|
||||
);
|
||||
|
||||
export const edgelessToolbarThemeContext = createContext<ColorScheme>(
|
||||
Symbol('edgelessToolbarThemeContext')
|
||||
);
|
||||
|
||||
export const edgelessToolbarContext = createContext<EdgelessToolbarWidget>(
|
||||
Symbol('edgelessToolbarContext')
|
||||
);
|
||||
@@ -0,0 +1,103 @@
|
||||
import { BlockSuiteError } from '@blocksuite/global/exceptions';
|
||||
|
||||
// more than 100% due to the shadow
|
||||
const leaveToPercent = `calc(100% + 10px)`;
|
||||
|
||||
export interface MenuPopper<T extends HTMLElement> {
|
||||
element: T;
|
||||
dispose: () => void;
|
||||
cancel?: () => void;
|
||||
}
|
||||
|
||||
// store active poppers
|
||||
const popMap = new WeakMap<HTMLElement, Map<string, MenuPopper<HTMLElement>>>();
|
||||
|
||||
function animateEnter(el: HTMLElement) {
|
||||
el.style.transform = 'translateY(0)';
|
||||
}
|
||||
function animateLeave(el: HTMLElement) {
|
||||
el.style.transform = `translateY(${leaveToPercent})`;
|
||||
}
|
||||
|
||||
export function createPopper<T extends keyof HTMLElementTagNameMap>(
|
||||
tagName: T,
|
||||
reference: HTMLElement,
|
||||
options?: {
|
||||
/** transition duration in ms */
|
||||
duration?: number;
|
||||
onDispose?: () => void;
|
||||
setProps?: (ele: HTMLElementTagNameMap[T]) => void;
|
||||
}
|
||||
): MenuPopper<HTMLElementTagNameMap[T]> {
|
||||
const duration = options?.duration ?? 230;
|
||||
|
||||
if (!popMap.has(reference)) popMap.set(reference, new Map());
|
||||
const elMap = popMap.get(reference);
|
||||
// if there is already a popper, cancel leave transition and apply enter transition
|
||||
if (elMap && elMap.has(tagName)) {
|
||||
const popper = elMap.get(tagName);
|
||||
if (popper) {
|
||||
popper.cancel?.();
|
||||
requestAnimationFrame(() => animateEnter(popper.element));
|
||||
return popper as MenuPopper<HTMLElementTagNameMap[T]>;
|
||||
}
|
||||
}
|
||||
|
||||
const clipWrapper = document.createElement('div');
|
||||
const menu = document.createElement(tagName);
|
||||
options?.setProps?.(menu);
|
||||
clipWrapper.append(menu);
|
||||
if (!reference.shadowRoot) {
|
||||
throw new BlockSuiteError(
|
||||
BlockSuiteError.ErrorCode.ValueNotExists,
|
||||
'reference must be a shadow root'
|
||||
);
|
||||
}
|
||||
reference.shadowRoot.append(clipWrapper);
|
||||
|
||||
// apply enter transition
|
||||
menu.style.transition = `all ${duration}ms ease`;
|
||||
animateLeave(menu);
|
||||
requestAnimationFrame(() => animateEnter(menu));
|
||||
|
||||
Object.assign(clipWrapper.style, {
|
||||
height: '100px',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
boxSizing: 'border-box',
|
||||
left: '0px',
|
||||
bottom: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'end',
|
||||
});
|
||||
|
||||
Object.assign(menu.style, {
|
||||
width: '100%',
|
||||
marginLeft: '30px',
|
||||
maxWidth: 'calc(100% - 60px)',
|
||||
bottom: '0%',
|
||||
pointerEvents: 'auto',
|
||||
});
|
||||
const remove = () => {
|
||||
clipWrapper.remove();
|
||||
menu.remove();
|
||||
popMap.get(reference)?.delete(tagName);
|
||||
options?.onDispose?.();
|
||||
};
|
||||
|
||||
const popper: MenuPopper<HTMLElementTagNameMap[T]> = {
|
||||
element: menu,
|
||||
dispose: () => {
|
||||
// apply leave transition
|
||||
animateLeave(menu);
|
||||
menu.addEventListener('transitionend', remove, { once: true });
|
||||
popper.cancel = () => menu.removeEventListener('transitionend', remove);
|
||||
},
|
||||
};
|
||||
|
||||
popMap.get(reference)?.set(tagName, popper);
|
||||
return popper;
|
||||
}
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
import type { ShapeName } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditPropsStore,
|
||||
ThemeProvider,
|
||||
ViewportElementProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import {
|
||||
type ReactiveController,
|
||||
type ReactiveControllerHost,
|
||||
render,
|
||||
} from 'lit';
|
||||
|
||||
import {
|
||||
type ElementDragEvent,
|
||||
mouseResolver,
|
||||
touchResolver,
|
||||
} from './event-resolver.js';
|
||||
import {
|
||||
createShapeDraggingOverlay,
|
||||
defaultInfo,
|
||||
type DraggingInfo,
|
||||
} from './overlay-factory.js';
|
||||
import {
|
||||
defaultIsValidMove,
|
||||
type EdgelessDraggableElementHost,
|
||||
type EdgelessDraggableElementOptions,
|
||||
type ElementInfo,
|
||||
type OverlayLayer,
|
||||
} from './types.js';
|
||||
|
||||
interface ReactiveState<T> {
|
||||
cancelled: boolean;
|
||||
draggingElement: ElementInfo<T> | null;
|
||||
dragOut: boolean | null;
|
||||
}
|
||||
interface EventCache {
|
||||
onMouseUp?: (e: MouseEvent) => void;
|
||||
onMouseMove?: (e: MouseEvent) => void;
|
||||
onTouchMove?: (e: TouchEvent) => void;
|
||||
onTouchEnd?: (e: TouchEvent) => void;
|
||||
}
|
||||
|
||||
export class EdgelessDraggableElementController<T>
|
||||
implements ReactiveController
|
||||
{
|
||||
clearTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
events: EventCache = {};
|
||||
|
||||
info = defaultInfo as DraggingInfo<T>;
|
||||
|
||||
overlay: OverlayLayer | null = null;
|
||||
|
||||
states: ReactiveState<T> = {
|
||||
cancelled: false,
|
||||
draggingElement: null,
|
||||
dragOut: null,
|
||||
};
|
||||
|
||||
constructor(
|
||||
public host: EdgelessDraggableElementHost & ReactiveControllerHost,
|
||||
public options: EdgelessDraggableElementOptions<T>
|
||||
) {
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.options.edgeless.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* let overlay shape animate back to the original position
|
||||
*/
|
||||
private _animateCancelDrop(onFinished?: () => void, duration = 230) {
|
||||
const { overlay, info } = this;
|
||||
if (!overlay) return;
|
||||
this.options?.onCanceled?.(overlay, info.elementInfo);
|
||||
// unlock pointer events
|
||||
overlay.mask.style.pointerEvents = 'none';
|
||||
// clip bottom
|
||||
if (info.scopeRect) {
|
||||
overlay.mask.style.height =
|
||||
info.scopeRect.bottom - info.edgelessRect.top + 'px';
|
||||
}
|
||||
|
||||
const { element, elementRectOriginal } = info;
|
||||
|
||||
const newShapeRect = element.getBoundingClientRect();
|
||||
const x = newShapeRect.left - elementRectOriginal.left;
|
||||
const y = newShapeRect.top - elementRectOriginal.top;
|
||||
|
||||
// apply a transition
|
||||
overlay.element.style.transition = `transform ${duration}ms ease`;
|
||||
overlay.element.style.setProperty('--translate-x', `${x}px`);
|
||||
overlay.element.style.setProperty('--translate-y', `${y}px`);
|
||||
overlay.transitionWrapper.style.setProperty('--scale', '1');
|
||||
|
||||
this.clearTimeout = setTimeout(() => {
|
||||
if (onFinished) return onFinished();
|
||||
this.reset();
|
||||
this.removeAllEvents();
|
||||
this.clearTimeout = null;
|
||||
}, duration);
|
||||
}
|
||||
|
||||
private _createOverlay({ x, y }: Pick<ElementDragEvent, 'x' | 'y'>) {
|
||||
const { edgeless } = this.options;
|
||||
const { elementInfo, elementRectOriginal, offsetPos, edgelessRect } =
|
||||
this.info;
|
||||
|
||||
this.reset();
|
||||
this._updateState('draggingElement', elementInfo);
|
||||
this.overlay = createShapeDraggingOverlay(this.info);
|
||||
|
||||
const { overlay } = this;
|
||||
// init shape position with 'left' and 'top';
|
||||
const { width, height, left, top } = elementRectOriginal;
|
||||
const relativeX = left - edgelessRect.left;
|
||||
const relativeY = top - edgelessRect.top;
|
||||
// make sure the transform origin is the same as the mouse position
|
||||
const ox = `${(((x - left) / width) * 100).toFixed(0)}%`;
|
||||
const oy = `${(((y - top) / height) * 100).toFixed(0)}%`;
|
||||
Object.assign(overlay.element.style, {
|
||||
left: `${relativeX}px`,
|
||||
top: `${relativeY}px`,
|
||||
});
|
||||
overlay.element.style.setProperty('--translate-x', `${offsetPos.x}px`);
|
||||
overlay.element.style.setProperty('--translate-y', `${offsetPos.y}px`);
|
||||
overlay.transitionWrapper.style.transformOrigin = `${ox} ${oy}`;
|
||||
|
||||
const shapeName = (elementInfo as ElementInfo<{ name: ShapeName }>).data
|
||||
.name;
|
||||
const { fillColor, strokeColor } =
|
||||
edgeless.host.std.get(EditPropsStore).lastProps$.value[
|
||||
`shape:${shapeName}`
|
||||
] || {};
|
||||
const color = edgeless.host.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(fillColor);
|
||||
const stroke = edgeless.host.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(strokeColor);
|
||||
overlay.element.style.setProperty('color', color);
|
||||
overlay.element.style.setProperty('stroke', stroke);
|
||||
// lifecycle hook
|
||||
this.options.onOverlayCreated?.(overlay, elementInfo);
|
||||
}
|
||||
|
||||
private _onDragEnd() {
|
||||
const { overlay, info, options } = this;
|
||||
const { startTime, elementInfo, edgelessRect, validMoved } = info;
|
||||
const { clickThreshold = 1500 } = options;
|
||||
const zoom = this.gfx.viewport.zoom;
|
||||
|
||||
if (!validMoved) {
|
||||
const duration = Date.now() - startTime;
|
||||
if (duration < clickThreshold) {
|
||||
options.onElementClick?.(info.elementInfo);
|
||||
if (options.clickToDrag) {
|
||||
this._createOverlay(info.startPos);
|
||||
this.info.moved = true;
|
||||
setTimeout(() => {
|
||||
this._updateOverlayScale(zoom);
|
||||
}, 50);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.states.dragOut && !this.states.cancelled && overlay) {
|
||||
const rect = overlay.transitionWrapper.getBoundingClientRect();
|
||||
const [modelX, modelY] = this.gfx.viewport.toModelCoord(
|
||||
rect.left - edgelessRect.left,
|
||||
rect.top - edgelessRect.top
|
||||
);
|
||||
const bound = new Bound(
|
||||
modelX,
|
||||
modelY,
|
||||
rect.width / zoom,
|
||||
rect.height / zoom
|
||||
);
|
||||
options?.onDrop?.(elementInfo, bound);
|
||||
|
||||
this.reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.states.dragOut) this._animateCancelDrop();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _onDragMove(e: ElementDragEvent) {
|
||||
if (this.states.cancelled) return;
|
||||
const { info, options } = this;
|
||||
|
||||
// first move
|
||||
if (!info.moved) {
|
||||
info.moved = true;
|
||||
this._createOverlay(e);
|
||||
}
|
||||
|
||||
const { overlay } = this;
|
||||
if (!overlay) return;
|
||||
const { x, y } = e;
|
||||
const { startPos, scopeRect } = info;
|
||||
const offsetX = x - startPos.x;
|
||||
const offsetY = y - startPos.y;
|
||||
info.offsetPos = { x: offsetX, y: offsetY };
|
||||
|
||||
if (!info.validMoved) {
|
||||
const isValidMove = options.isValidMove ?? defaultIsValidMove;
|
||||
info.validMoved = isValidMove(info.offsetPos);
|
||||
}
|
||||
|
||||
// check if inside scopeElement
|
||||
const newDragOut =
|
||||
!scopeRect ||
|
||||
y < scopeRect.top ||
|
||||
y > scopeRect.bottom ||
|
||||
x < scopeRect.left ||
|
||||
x > scopeRect.right;
|
||||
if (newDragOut !== this.states.dragOut)
|
||||
options.onEnterOrLeaveScope?.(overlay, newDragOut);
|
||||
this._updateState('dragOut', newDragOut);
|
||||
|
||||
// apply transform
|
||||
// - move shape with translate
|
||||
overlay.element.style.setProperty('--translate-x', `${offsetX}px`);
|
||||
overlay.element.style.setProperty('--translate-y', `${offsetY}px`);
|
||||
// - scale shape with scale
|
||||
const zoom = this.gfx.viewport.zoom;
|
||||
this._updateOverlayScale(zoom);
|
||||
}
|
||||
|
||||
private _onDragStart(e: ElementDragEvent, elementInfo: ElementInfo<T>) {
|
||||
const { scopeElement, edgeless } = this.options;
|
||||
e.originalEvent.stopPropagation();
|
||||
e.originalEvent.preventDefault();
|
||||
|
||||
// Safari compatibility
|
||||
// Cannot get edgeless.host.getBoundingClientRect().width in Safari (Always 0)
|
||||
const edgelessRect = edgeless.host.getBoundingClientRect();
|
||||
if (edgelessRect.width === 0) {
|
||||
const { viewport } = edgeless.std.get(ViewportElementProvider);
|
||||
edgelessRect.width = viewport.clientWidth;
|
||||
}
|
||||
|
||||
this.info = {
|
||||
startTime: Date.now(),
|
||||
startPos: { x: e.x, y: e.y },
|
||||
offsetPos: { x: 0, y: 0 },
|
||||
scopeRect: scopeElement?.getBoundingClientRect() ?? null,
|
||||
edgelessRect,
|
||||
elementRectOriginal: e.el.getBoundingClientRect(),
|
||||
element: e.el,
|
||||
elementInfo,
|
||||
moved: false,
|
||||
validMoved: false,
|
||||
parentToMount: edgeless.host,
|
||||
};
|
||||
|
||||
this.removeAllEvents();
|
||||
if (e.inputType === 'mouse') {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
this._onDragMove(mouseResolver(e));
|
||||
};
|
||||
const onMouseUp = (_: MouseEvent) => {
|
||||
const finished = this._onDragEnd();
|
||||
if (finished) {
|
||||
edgeless.host.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
};
|
||||
edgeless.host.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
this.events = { onMouseMove, onMouseUp };
|
||||
} else {
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
this._onDragMove(touchResolver(e));
|
||||
};
|
||||
const onTouchEnd = (_: TouchEvent) => {
|
||||
const finished = this._onDragEnd();
|
||||
if (finished) {
|
||||
edgeless.host.removeEventListener('touchmove', onTouchMove);
|
||||
window.removeEventListener('touchend', onTouchEnd);
|
||||
}
|
||||
};
|
||||
edgeless.host.addEventListener('touchmove', onTouchMove);
|
||||
window.addEventListener('touchend', onTouchEnd);
|
||||
this.events = { onTouchMove, onTouchEnd };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update overlay shape scale according to the current zoom level
|
||||
*/
|
||||
private _updateOverlayScale(zoom: number) {
|
||||
const transitionWrapper = this.overlay?.transitionWrapper;
|
||||
if (!transitionWrapper) return;
|
||||
|
||||
const standardWidth =
|
||||
this.info.elementInfo.standardWidth ?? this.options.standardWidth ?? 100;
|
||||
|
||||
const { elementRectOriginal } = this.info;
|
||||
const scale = (standardWidth * zoom) / elementRectOriginal.width;
|
||||
|
||||
const clickToDragScale = this.options.clickToDragScale ?? 1.2;
|
||||
|
||||
const finalScale = this.states.dragOut
|
||||
? scale
|
||||
: this.options.clickToDrag
|
||||
? clickToDragScale
|
||||
: 1;
|
||||
transitionWrapper.style.setProperty('--scale', finalScale.toFixed(2));
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
private _updateState<Key extends keyof ReactiveState<T>>(
|
||||
key: Key,
|
||||
value: ReactiveState<T>[Key]
|
||||
) {
|
||||
this.states[key] = value;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
private _updateStates(states: Partial<ReactiveState<T>>) {
|
||||
Object.assign(this.states, states);
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current dragging & animate even if dragOut
|
||||
*/
|
||||
cancel() {
|
||||
if (this.states.cancelled) return;
|
||||
this._updateState('cancelled', true);
|
||||
this._animateCancelDrop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link cancel} but without animation
|
||||
*/
|
||||
cancelWithoutAnimation() {
|
||||
if (this.states.cancelled) return;
|
||||
this._updateState('cancelled', true);
|
||||
this.reset();
|
||||
this.removeAllEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* A workaround to apply click event manually
|
||||
*/
|
||||
clickToDrag(target: HTMLElement, startPos: { x: number; y: number }) {
|
||||
if (!this.options.clickToDrag) {
|
||||
this.options.clickToDrag = true;
|
||||
console.warn(
|
||||
'clickToDrag is not enabled, it will be enabled automatically'
|
||||
);
|
||||
}
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const targetCenter = {
|
||||
x: targetRect.left + targetRect.width / 2,
|
||||
y: targetRect.top + targetRect.height / 2,
|
||||
};
|
||||
|
||||
const mouseDownEvent = new MouseEvent('mousedown', {
|
||||
clientX: targetCenter.x,
|
||||
clientY: targetCenter.y,
|
||||
});
|
||||
const mouseUpEvent = new MouseEvent('mouseup', {
|
||||
clientX: targetCenter.x,
|
||||
clientY: targetCenter.y,
|
||||
});
|
||||
target.dispatchEvent(mouseDownEvent);
|
||||
window.dispatchEvent(mouseUpEvent);
|
||||
|
||||
const mouseMoveEvent = new MouseEvent('mousemove', {
|
||||
clientX: startPos.x,
|
||||
clientY: startPos.y,
|
||||
});
|
||||
|
||||
this.options.edgeless.host.dispatchEvent(mouseMoveEvent);
|
||||
}
|
||||
|
||||
dragAndMoveTo(target: HTMLElement, to: { x: number; y: number }) {
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const targetCenter = {
|
||||
x: targetRect.left + targetRect.width / 2,
|
||||
y: targetRect.top + targetRect.height / 2,
|
||||
};
|
||||
|
||||
const mouseDownEvent = new MouseEvent('mousedown', {
|
||||
clientX: targetCenter.x,
|
||||
clientY: targetCenter.y,
|
||||
});
|
||||
const mouseMoveStartEvent = new MouseEvent('mousemove', {
|
||||
clientX: targetCenter.x,
|
||||
clientY: targetCenter.y,
|
||||
});
|
||||
const mouseMoveToEvent = new MouseEvent('mousemove', {
|
||||
clientX: to.x,
|
||||
clientY: to.y,
|
||||
});
|
||||
target.dispatchEvent(mouseDownEvent);
|
||||
this.options.edgeless.host.dispatchEvent(mouseMoveStartEvent);
|
||||
this.options.edgeless.host.dispatchEvent(mouseMoveToEvent);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
this.host.disposables.add(
|
||||
this.gfx.viewport.viewportUpdated.subscribe(({ zoom }) => {
|
||||
this._updateOverlayScale(zoom);
|
||||
})
|
||||
);
|
||||
|
||||
this.host.disposables.addFromEvent(
|
||||
window,
|
||||
'keydown',
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && this.states.draggingElement) this.cancel();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this.removeAllEvents();
|
||||
this.reset();
|
||||
}
|
||||
|
||||
onMouseDown(e: MouseEvent, elementInfo: ElementInfo<T>) {
|
||||
this._onDragStart(mouseResolver(e), elementInfo);
|
||||
}
|
||||
|
||||
onTouchStart(e: TouchEvent, elementInfo: ElementInfo<T>) {
|
||||
this._onDragStart(touchResolver(e), elementInfo);
|
||||
}
|
||||
|
||||
removeAllEvents() {
|
||||
const { events, options } = this;
|
||||
const host = options.edgeless.host;
|
||||
const { onMouseUp, onMouseMove, onTouchMove, onTouchEnd } = events;
|
||||
onMouseUp && window.removeEventListener('mouseup', onMouseUp);
|
||||
onMouseMove && host && host.removeEventListener('mousemove', onMouseMove);
|
||||
onTouchMove && host && host.removeEventListener('touchmove', onTouchMove);
|
||||
onTouchEnd && window.removeEventListener('touchend', onTouchEnd);
|
||||
this.events = {};
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.clearTimeout) clearTimeout(this.clearTimeout);
|
||||
this.overlay?.mask.remove();
|
||||
this.overlay = null;
|
||||
this._updateStates({
|
||||
cancelled: false,
|
||||
draggingElement: null,
|
||||
dragOut: null,
|
||||
});
|
||||
}
|
||||
|
||||
updateElementInfo(elementInfo: Partial<ElementInfo<T>>) {
|
||||
this.info.elementInfo = {
|
||||
...this.info.elementInfo,
|
||||
...elementInfo,
|
||||
};
|
||||
|
||||
if (elementInfo.preview && this.overlay) {
|
||||
render(elementInfo.preview, this.overlay.transitionWrapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type ElementDragEvent = {
|
||||
inputType: 'mouse' | 'touch';
|
||||
x: number;
|
||||
y: number;
|
||||
el: HTMLElement;
|
||||
originalEvent: MouseEvent | TouchEvent;
|
||||
};
|
||||
|
||||
export const touchResolver = (event: TouchEvent) =>
|
||||
({
|
||||
inputType: 'touch',
|
||||
x: event.touches[0].clientX,
|
||||
y: event.touches[0].clientY,
|
||||
el: event.currentTarget as HTMLElement,
|
||||
originalEvent: event,
|
||||
}) satisfies ElementDragEvent;
|
||||
|
||||
export const mouseResolver = (event: MouseEvent) =>
|
||||
({
|
||||
inputType: 'mouse',
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
el: event.currentTarget as HTMLElement,
|
||||
originalEvent: event,
|
||||
}) satisfies ElementDragEvent;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './draggable-element.controller.js';
|
||||
@@ -0,0 +1,96 @@
|
||||
import { render } from 'lit';
|
||||
|
||||
import type { ElementInfo, OverlayLayer } from './types.js';
|
||||
|
||||
export type DraggingInfo<T> = {
|
||||
startPos: { x: number; y: number };
|
||||
offsetPos: { x: number; y: number };
|
||||
startTime: number;
|
||||
scopeRect: DOMRect | null;
|
||||
edgelessRect: DOMRect;
|
||||
elementRectOriginal: DOMRect;
|
||||
element: HTMLElement;
|
||||
elementInfo: ElementInfo<T>;
|
||||
parentToMount: HTMLElement;
|
||||
moved: boolean;
|
||||
validMoved: boolean;
|
||||
};
|
||||
|
||||
export const defaultInfo = {
|
||||
startPos: { x: 0, y: 0 },
|
||||
offsetPos: { x: 0, y: 0 },
|
||||
startTime: 0,
|
||||
scopeRect: {} as DOMRect,
|
||||
edgelessRect: {} as DOMRect,
|
||||
elementRectOriginal: {} as DOMRect,
|
||||
element: null as unknown as HTMLElement,
|
||||
elementInfo: null as unknown as ElementInfo<unknown>,
|
||||
parentToMount: null as unknown as HTMLElement,
|
||||
moved: false,
|
||||
validMoved: false,
|
||||
} satisfies DraggingInfo<unknown>;
|
||||
|
||||
const className = (name: string) =>
|
||||
`edgeless-draggable-control-overlay-${name}`;
|
||||
const addClass = (node: HTMLElement, name: string) =>
|
||||
node.classList.add(className(name));
|
||||
|
||||
export const createShapeDraggingOverlay = <T>(
|
||||
info: DraggingInfo<T>
|
||||
): OverlayLayer => {
|
||||
const { edgelessRect, parentToMount, element: originalElement } = info;
|
||||
const elementStyle = getComputedStyle(originalElement);
|
||||
const mask = document.createElement('div');
|
||||
addClass(mask, 'mask');
|
||||
Object.assign(mask.style, {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: edgelessRect.width + 'px',
|
||||
height: edgelessRect.height + 'px',
|
||||
overflow: 'hidden',
|
||||
zIndex: '9999',
|
||||
|
||||
// for debug purpose
|
||||
// background: 'rgba(255, 0, 0, 0.1)',
|
||||
});
|
||||
|
||||
const element = document.createElement('div');
|
||||
addClass(element, 'element');
|
||||
const transitionWrapper = document.createElement('div');
|
||||
addClass(transitionWrapper, 'transition-wrapper');
|
||||
Object.assign(transitionWrapper.style, {
|
||||
transition: 'all 0.18s ease',
|
||||
transform: 'scale(var(--scale, 1)) rotate(var(--rotate, 0deg))',
|
||||
width: elementStyle.width,
|
||||
height: elementStyle.height,
|
||||
});
|
||||
transitionWrapper.style.setProperty('--rotate', '0deg');
|
||||
transitionWrapper.style.setProperty('--scale', '1');
|
||||
|
||||
render(info.elementInfo.preview, transitionWrapper);
|
||||
|
||||
Object.assign(element.style, {
|
||||
transform:
|
||||
'translate(var(--translate-x, 0), var(--translate-y, 0)) rotate(var(--rotate, 0deg)) scale(var(--scale, 1))',
|
||||
position: 'absolute',
|
||||
cursor: 'grabbing',
|
||||
transition: 'inherit',
|
||||
});
|
||||
|
||||
const styleTag = document.createElement('style');
|
||||
styleTag.textContent = `
|
||||
.${className('transition-wrapper')} > * {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
mask.append(styleTag);
|
||||
|
||||
element.append(transitionWrapper);
|
||||
mask.append(element);
|
||||
parentToMount.append(mask);
|
||||
|
||||
return { mask, element, transitionWrapper };
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Bound } from '@blocksuite/global/gfx';
|
||||
import type { DisposableClass } from '@blocksuite/global/lit';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
export interface EdgelessDraggableElementHost extends DisposableClass {}
|
||||
|
||||
export interface OverlayLayer {
|
||||
/**
|
||||
* The root element of the overlay,
|
||||
* used to handle clip & prevent pointer events
|
||||
*/
|
||||
mask: HTMLElement;
|
||||
/**
|
||||
* The real preview element
|
||||
*/
|
||||
element: HTMLElement;
|
||||
/**
|
||||
* The wrapper that contains the preview element,
|
||||
* different from the element, this element has transition effect
|
||||
*/
|
||||
transitionWrapper: HTMLElement;
|
||||
}
|
||||
|
||||
export interface EdgelessDraggableElementOptions<T> {
|
||||
edgeless: BlockComponent;
|
||||
/**
|
||||
* In which element that the target should be dragged out
|
||||
* If not provided, recognized as the drag-out whenever dragging
|
||||
*/
|
||||
scopeElement?: HTMLElement;
|
||||
/**
|
||||
* The width of the element when placed to canvas
|
||||
* @default 100
|
||||
*/
|
||||
standardWidth?: number;
|
||||
|
||||
/**
|
||||
* the threshold of mousedown and mouseup duration in ms
|
||||
* if the duration is less than this value, it will be treated as a click
|
||||
* @default 1500
|
||||
*/
|
||||
clickThreshold?: number;
|
||||
|
||||
/**
|
||||
* if enabled, when clicked, will trigger drag, press ESC or reclick to cancel
|
||||
*/
|
||||
clickToDrag?: boolean;
|
||||
/**
|
||||
* the scale of the element inside {@link EdgelessDraggableElementController.scopeElement}
|
||||
* when {@link EdgelessDraggableElementOptions.clickToDrag} is enabled
|
||||
* @default 1.2
|
||||
*/
|
||||
clickToDragScale?: number;
|
||||
|
||||
/**
|
||||
* To verify if the move is valid
|
||||
*/
|
||||
isValidMove?: (offset: { x: number; y: number }) => boolean;
|
||||
|
||||
/**
|
||||
* when element is clicked - mouse down and up without moving
|
||||
*/
|
||||
onElementClick?: (element: ElementInfo<T>) => void;
|
||||
/**
|
||||
* when mouse down and moved, create overlay, customize overlay here
|
||||
*/
|
||||
onOverlayCreated?: (overlay: OverlayLayer, element: ElementInfo<T>) => void;
|
||||
/**
|
||||
* trigger when enter/leave the scope element
|
||||
*/
|
||||
onEnterOrLeaveScope?: (overlay: OverlayLayer, isOutside?: boolean) => void;
|
||||
/**
|
||||
* Drop the element on edgeless canvas
|
||||
*/
|
||||
onDrop?: (element: ElementInfo<T>, bound: Bound) => void;
|
||||
|
||||
/**
|
||||
* - ESC pressed
|
||||
* - or not dragged out and released
|
||||
*/
|
||||
onCanceled?: (overlay: OverlayLayer, element: ElementInfo<T>) => void;
|
||||
}
|
||||
|
||||
export type ElementInfo<T> = {
|
||||
// TODO: maybe make it optional, if not provided, clone event target
|
||||
preview: TemplateResult;
|
||||
data: T;
|
||||
/**
|
||||
* Override the value in {@link EdgelessDraggableElementOptions.standardWidth}
|
||||
*/
|
||||
standardWidth?: number;
|
||||
};
|
||||
|
||||
export const defaultIsValidMove = (offset: { x: number; y: number }) => {
|
||||
return Math.abs(offset.x) > 50 || Math.abs(offset.y) > 50;
|
||||
};
|
||||
@@ -0,0 +1,729 @@
|
||||
/* oxlint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type MenuHandler,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import {
|
||||
darkToolbarStyles,
|
||||
lightToolbarStyles,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import { ColorScheme, type RootBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditPropsStore,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
ArrowLeftSmallIcon,
|
||||
ArrowRightSmallIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { autoPlacement, offset } from '@floating-ui/dom';
|
||||
import { ContextProvider } from '@lit/context';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { baseTheme, cssVar } from '@toeverything/theme';
|
||||
import { css, html, nothing, unsafeCSS } from 'lit';
|
||||
import { query, state } from 'lit/decorators.js';
|
||||
import { cache } from 'lit/directives/cache.js';
|
||||
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import {
|
||||
edgelessToolbarContext,
|
||||
type EdgelessToolbarSlots,
|
||||
edgelessToolbarSlotsContext,
|
||||
edgelessToolbarThemeContext,
|
||||
} from './context.js';
|
||||
import type { MenuPopper } from './create-popper.js';
|
||||
import {
|
||||
QuickToolIdentifier,
|
||||
SeniorToolIdentifier,
|
||||
} from './extension/index.js';
|
||||
|
||||
const TOOLBAR_PADDING_X = 12;
|
||||
const TOOLBAR_HEIGHT = 64;
|
||||
const QUICK_TOOLS_GAP = 10;
|
||||
const QUICK_TOOL_SIZE = 36;
|
||||
const QUICK_TOOL_MORE_SIZE = 20;
|
||||
const SENIOR_TOOLS_GAP = 0;
|
||||
const SENIOR_TOOL_WIDTH = 96;
|
||||
const SENIOR_TOOL_NAV_SIZE = 20;
|
||||
const DIVIDER_WIDTH = 8;
|
||||
const DIVIDER_SPACE = 8;
|
||||
const SAFE_AREA_WIDTH = 64;
|
||||
|
||||
export const EDGELESS_TOOLBAR_WIDGET = 'edgeless-toolbar-widget';
|
||||
export class EdgelessToolbarWidget extends WidgetComponent<RootBlockModel> {
|
||||
static override styles = css`
|
||||
:host {
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: calc(50%);
|
||||
transform: translateX(-50%);
|
||||
bottom: 0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.edgeless-toolbar-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
${unsafeCSS(lightToolbarStyles('.edgeless-toolbar-wrapper'))}
|
||||
${unsafeCSS(darkToolbarStyles('.edgeless-toolbar-wrapper'))}
|
||||
|
||||
.edgeless-toolbar-toggle-control {
|
||||
pointer-events: auto;
|
||||
padding-bottom: 16px;
|
||||
width: fit-content;
|
||||
max-width: calc(100% - ${unsafeCSS(SAFE_AREA_WIDTH)}px * 2);
|
||||
min-width: 264px;
|
||||
}
|
||||
.edgeless-toolbar-toggle-control[data-enable='true'] {
|
||||
transition: 0.23s ease;
|
||||
padding-top: 100px;
|
||||
transform: translateY(100px);
|
||||
}
|
||||
.edgeless-toolbar-toggle-control[data-enable='true']:hover {
|
||||
padding-top: 0;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.edgeless-toolbar-smooth-corner {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
.edgeless-toolbar-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 ${unsafeCSS(TOOLBAR_PADDING_X)}px;
|
||||
height: ${unsafeCSS(TOOLBAR_HEIGHT)}px;
|
||||
}
|
||||
:host([disabled]) .edgeless-toolbar-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
.edgeless-toolbar-container[level='second'] {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.edgeless-toolbar-container[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.quick-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: ${unsafeCSS(QUICK_TOOLS_GAP)}px;
|
||||
}
|
||||
.full-divider {
|
||||
width: ${unsafeCSS(DIVIDER_WIDTH)}px;
|
||||
height: 100%;
|
||||
margin: 0 ${unsafeCSS(DIVIDER_SPACE)}px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.full-divider::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background-color: var(--affine-border-color);
|
||||
}
|
||||
.pen-and-eraser {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
.senior-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: ${unsafeCSS(SENIOR_TOOLS_GAP)}px;
|
||||
height: 100%;
|
||||
min-width: ${unsafeCSS(SENIOR_TOOL_WIDTH)}px;
|
||||
}
|
||||
.quick-tool-item {
|
||||
width: ${unsafeCSS(QUICK_TOOL_SIZE)}px;
|
||||
height: ${unsafeCSS(QUICK_TOOL_SIZE)}px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.quick-tool-more {
|
||||
width: 0;
|
||||
height: ${unsafeCSS(QUICK_TOOL_SIZE)}px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: all 0.23s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-dense-quick='true'] .quick-tool-more {
|
||||
width: ${unsafeCSS(QUICK_TOOL_MORE_SIZE)}px;
|
||||
margin-left: ${unsafeCSS(DIVIDER_SPACE)}px;
|
||||
}
|
||||
.quick-tool-more-button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.senior-tool-item {
|
||||
width: ${unsafeCSS(SENIOR_TOOL_WIDTH)}px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.senior-nav-button-wrapper {
|
||||
flex-shrink: 0;
|
||||
width: 0px;
|
||||
height: ${unsafeCSS(SENIOR_TOOL_NAV_SIZE)}px;
|
||||
transition: width 0.23s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.senior-nav-button {
|
||||
padding: 0;
|
||||
}
|
||||
.senior-nav-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
[data-dense-senior='true'] .senior-nav-button-wrapper {
|
||||
width: ${unsafeCSS(SENIOR_TOOL_NAV_SIZE)}px;
|
||||
}
|
||||
[data-dense-senior='true'] .senior-nav-button-wrapper.prev {
|
||||
margin-right: ${unsafeCSS(DIVIDER_SPACE)}px;
|
||||
}
|
||||
[data-dense-senior='true'] .senior-nav-button-wrapper.next {
|
||||
margin-left: ${unsafeCSS(DIVIDER_SPACE)}px;
|
||||
}
|
||||
.transform-button svg {
|
||||
transition: 0.3s ease-in-out;
|
||||
}
|
||||
.transform-button:hover svg {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _appTheme$ = computed(() => {
|
||||
return this.std.get(ThemeProvider).app$.value;
|
||||
});
|
||||
|
||||
private _moreQuickToolsMenu: MenuHandler | null = null;
|
||||
|
||||
private _moreQuickToolsMenuRef: HTMLElement | null = null;
|
||||
|
||||
@state()
|
||||
accessor containerWidth = 1920;
|
||||
|
||||
private readonly _onContainerResize = debounce(
|
||||
({ w }: { w: number }) => {
|
||||
if (!this.isConnected) return;
|
||||
|
||||
this.slots.resize.next({ w, h: TOOLBAR_HEIGHT });
|
||||
this.containerWidth = w;
|
||||
|
||||
if (this._denseSeniorTools) {
|
||||
this.scrollSeniorToolIndex = Math.min(
|
||||
this._seniorTools.length - this.scrollSeniorToolSize,
|
||||
this.scrollSeniorToolIndex
|
||||
);
|
||||
} else {
|
||||
this.scrollSeniorToolIndex = 0;
|
||||
}
|
||||
|
||||
if (
|
||||
this._denseQuickTools &&
|
||||
this._moreQuickToolsMenu &&
|
||||
this._moreQuickToolsMenuRef
|
||||
) {
|
||||
this._moreQuickToolsMenu.close();
|
||||
this._openMoreQuickToolsMenu({
|
||||
currentTarget: this._moreQuickToolsMenuRef,
|
||||
});
|
||||
}
|
||||
if (!this._denseQuickTools && this._moreQuickToolsMenu) {
|
||||
this._moreQuickToolsMenu.close();
|
||||
this._moreQuickToolsMenu = null;
|
||||
}
|
||||
},
|
||||
300,
|
||||
{ leading: true }
|
||||
);
|
||||
|
||||
private _resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
private readonly _slotsProvider = new ContextProvider(this, {
|
||||
context: edgelessToolbarSlotsContext,
|
||||
initialValue: { resize: new Subject() } satisfies EdgelessToolbarSlots,
|
||||
});
|
||||
|
||||
private readonly _themeProvider = new ContextProvider(this, {
|
||||
context: edgelessToolbarThemeContext,
|
||||
initialValue: ColorScheme.Light,
|
||||
});
|
||||
|
||||
private readonly _toolbarProvider = new ContextProvider(this, {
|
||||
context: edgelessToolbarContext,
|
||||
initialValue: this,
|
||||
});
|
||||
|
||||
activePopper: MenuPopper<HTMLElement> | null = null;
|
||||
|
||||
// calculate all the width manually
|
||||
private get _availableWidth() {
|
||||
return this.containerWidth - 2 * SAFE_AREA_WIDTH;
|
||||
}
|
||||
|
||||
private get _cachedPresentHideToolbar() {
|
||||
return !!this.std.get(EditPropsStore).getStorage('presentHideToolbar');
|
||||
}
|
||||
|
||||
private get _denseQuickTools() {
|
||||
return (
|
||||
this._availableWidth -
|
||||
this._seniorToolNavWidth -
|
||||
1 * SENIOR_TOOL_WIDTH -
|
||||
2 * TOOLBAR_PADDING_X <
|
||||
this._quickToolsWidthTotal
|
||||
);
|
||||
}
|
||||
|
||||
private get _denseSeniorTools() {
|
||||
return (
|
||||
this._availableWidth -
|
||||
this._quickToolsWidthTotal -
|
||||
this._spaceWidthTotal <
|
||||
this._seniorToolsWidthTotal
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* When enabled, the toolbar will auto-hide when the mouse is not over it.
|
||||
*/
|
||||
private get _enableAutoHide() {
|
||||
return (
|
||||
this.isPresentMode &&
|
||||
this._cachedPresentHideToolbar &&
|
||||
!this.presentSettingMenuShow &&
|
||||
!this.presentFrameMenuShow
|
||||
);
|
||||
}
|
||||
|
||||
private get _hiddenQuickTools() {
|
||||
return this._quickTools
|
||||
.slice(this._visibleQuickToolSize)
|
||||
.filter(tool => !!tool.menu);
|
||||
}
|
||||
|
||||
private get _quickTools() {
|
||||
const block = this.block;
|
||||
if (!block) {
|
||||
return [];
|
||||
}
|
||||
const quickTools = Array.from(
|
||||
this.std.provider.getAll(QuickToolIdentifier).values()
|
||||
);
|
||||
const gfx = this.std.get(GfxControllerIdentifier);
|
||||
return quickTools
|
||||
.map(tool =>
|
||||
tool({ block, gfx, toolbarContainer: this.toolbarContainer })
|
||||
)
|
||||
.filter(({ enable = true }) => enable);
|
||||
}
|
||||
|
||||
private get _quickToolsWidthTotal() {
|
||||
return (
|
||||
this._quickTools.length * (QUICK_TOOL_SIZE + QUICK_TOOLS_GAP) -
|
||||
QUICK_TOOLS_GAP
|
||||
);
|
||||
}
|
||||
|
||||
private get _seniorNextTooltip() {
|
||||
if (this._seniorScrollNextDisabled) return '';
|
||||
const nextTool =
|
||||
this._seniorTools[this.scrollSeniorToolIndex + this.scrollSeniorToolSize];
|
||||
return nextTool?.name ?? '';
|
||||
}
|
||||
|
||||
private get _seniorPrevTooltip() {
|
||||
if (this._seniorScrollPrevDisabled) return '';
|
||||
const prevTool = this._seniorTools[this.scrollSeniorToolIndex - 1];
|
||||
return prevTool?.name ?? '';
|
||||
}
|
||||
|
||||
private get _seniorScrollNextDisabled() {
|
||||
return (
|
||||
this.scrollSeniorToolIndex + this.scrollSeniorToolSize >=
|
||||
this._seniorTools.length
|
||||
);
|
||||
}
|
||||
|
||||
private get _seniorScrollPrevDisabled() {
|
||||
return this.scrollSeniorToolIndex === 0;
|
||||
}
|
||||
|
||||
private get _seniorToolNavWidth() {
|
||||
return this._denseSeniorTools
|
||||
? (SENIOR_TOOL_NAV_SIZE + DIVIDER_SPACE) * 2
|
||||
: 0;
|
||||
}
|
||||
|
||||
private get _seniorTools() {
|
||||
const block = this.block;
|
||||
if (!block) {
|
||||
return [];
|
||||
}
|
||||
const seniorTools = Array.from(
|
||||
this.std.provider.getAll(SeniorToolIdentifier).values()
|
||||
);
|
||||
const gfx = this.std.get(GfxControllerIdentifier);
|
||||
return seniorTools
|
||||
.map(tool =>
|
||||
tool({ block, gfx, toolbarContainer: this.toolbarContainer })
|
||||
)
|
||||
.filter(({ enable = true }) => enable);
|
||||
}
|
||||
|
||||
private get _seniorToolsWidthTotal() {
|
||||
return (
|
||||
this._seniorTools.length * (SENIOR_TOOL_WIDTH + SENIOR_TOOLS_GAP) -
|
||||
SENIOR_TOOLS_GAP
|
||||
);
|
||||
}
|
||||
|
||||
private get _spaceWidthTotal() {
|
||||
return DIVIDER_WIDTH + DIVIDER_SPACE * 2 + TOOLBAR_PADDING_X * 2;
|
||||
}
|
||||
|
||||
private get _visibleQuickToolSize() {
|
||||
if (!this._denseQuickTools) return this._quickTools.length;
|
||||
const availableWidth =
|
||||
this._availableWidth -
|
||||
this._seniorToolNavWidth -
|
||||
this._spaceWidthTotal -
|
||||
SENIOR_TOOL_WIDTH;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(availableWidth - QUICK_TOOL_MORE_SIZE - DIVIDER_SPACE) /
|
||||
(QUICK_TOOL_SIZE + QUICK_TOOLS_GAP)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
get edgelessTool() {
|
||||
// FIXME: maybe we need to fix this type
|
||||
return this.gfx.tool.currentToolOption$.value as { type: string };
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get isPresentMode() {
|
||||
return this.edgelessTool.type === 'frameNavigator';
|
||||
}
|
||||
|
||||
get scrollSeniorToolSize() {
|
||||
if (this._denseQuickTools) return 1;
|
||||
const seniorAvailableWidth =
|
||||
this._availableWidth - this._quickToolsWidthTotal - this._spaceWidthTotal;
|
||||
if (seniorAvailableWidth >= this._seniorToolsWidthTotal)
|
||||
return this._seniorTools.length;
|
||||
return (
|
||||
Math.floor(
|
||||
(seniorAvailableWidth - (SENIOR_TOOL_NAV_SIZE + DIVIDER_SPACE) * 2) /
|
||||
SENIOR_TOOL_WIDTH
|
||||
) || 1
|
||||
);
|
||||
}
|
||||
|
||||
get slots() {
|
||||
return this._slotsProvider.value;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
private _onSeniorNavNext() {
|
||||
if (this._seniorScrollNextDisabled) return;
|
||||
this.scrollSeniorToolIndex = Math.min(
|
||||
this._seniorTools.length - this.scrollSeniorToolSize,
|
||||
this.scrollSeniorToolIndex + this.scrollSeniorToolSize
|
||||
);
|
||||
}
|
||||
|
||||
private _onSeniorNavPrev() {
|
||||
if (this._seniorScrollPrevDisabled) return;
|
||||
this.scrollSeniorToolIndex = Math.max(
|
||||
0,
|
||||
this.scrollSeniorToolIndex - this.scrollSeniorToolSize
|
||||
);
|
||||
}
|
||||
|
||||
private _openMoreQuickToolsMenu(e: { currentTarget: HTMLElement }) {
|
||||
if (!this._hiddenQuickTools.length) return;
|
||||
|
||||
this._moreQuickToolsMenuRef = e.currentTarget;
|
||||
this._moreQuickToolsMenu = popMenu(
|
||||
popupTargetFromElement(e.currentTarget as HTMLElement),
|
||||
{
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['top'],
|
||||
}),
|
||||
offset({
|
||||
mainAxis: (TOOLBAR_HEIGHT - QUICK_TOOL_MORE_SIZE) / 2 + 8,
|
||||
}),
|
||||
],
|
||||
options: {
|
||||
onClose: () => {
|
||||
this._moreQuickToolsMenu = null;
|
||||
this._moreQuickToolsMenuRef = null;
|
||||
},
|
||||
items: this._hiddenQuickTools.map(tool => tool.menu!),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _renderContent() {
|
||||
return html`
|
||||
<div class="quick-tools">
|
||||
${this._quickTools
|
||||
.slice(0, this._visibleQuickToolSize)
|
||||
.map(
|
||||
tool => html`<div class="quick-tool-item">${tool.content}</div>`
|
||||
)}
|
||||
</div>
|
||||
<div class="quick-tool-more">
|
||||
<icon-button
|
||||
?disabled=${!this._denseQuickTools}
|
||||
.size=${20}
|
||||
class="quick-tool-more-button"
|
||||
@click=${this._openMoreQuickToolsMenu}
|
||||
?active=${this._quickTools
|
||||
.slice(this._visibleQuickToolSize)
|
||||
.some(tool => tool.type === this.edgelessTool?.type)}
|
||||
>
|
||||
${MoreHorizontalIcon({ width: '20px', height: '20px' })}
|
||||
<affine-tooltip tip-position="top" .offset=${25}>
|
||||
More Tools
|
||||
</affine-tooltip>
|
||||
</icon-button>
|
||||
</div>
|
||||
<div class="full-divider"></div>
|
||||
<div class="senior-nav-button-wrapper prev">
|
||||
<icon-button
|
||||
.size=${20}
|
||||
class="senior-nav-button"
|
||||
?disabled=${this._seniorScrollPrevDisabled}
|
||||
@click=${this._onSeniorNavPrev}
|
||||
>
|
||||
${ArrowLeftSmallIcon({ width: '20px', height: '20px' })}
|
||||
${cache(
|
||||
this._seniorPrevTooltip
|
||||
? html` <affine-tooltip tip-position="top" .offset=${4}>
|
||||
${this._seniorPrevTooltip}
|
||||
</affine-tooltip>`
|
||||
: nothing
|
||||
)}
|
||||
</icon-button>
|
||||
</div>
|
||||
<div class="senior-tools">
|
||||
${this._seniorTools
|
||||
.slice(
|
||||
this.scrollSeniorToolIndex,
|
||||
this.scrollSeniorToolIndex + this.scrollSeniorToolSize
|
||||
)
|
||||
.map(
|
||||
tool => html`<div class="senior-tool-item">${tool.content}</div>`
|
||||
)}
|
||||
</div>
|
||||
<div class="senior-nav-button-wrapper next">
|
||||
<icon-button
|
||||
.size=${20}
|
||||
class="senior-nav-button"
|
||||
?disabled=${this._seniorScrollNextDisabled}
|
||||
@click=${this._onSeniorNavNext}
|
||||
>
|
||||
${ArrowRightSmallIcon({ width: '20px', height: '20px' })}
|
||||
${cache(
|
||||
this._seniorNextTooltip
|
||||
? html` <affine-tooltip tip-position="top" .offset=${4}>
|
||||
${this._seniorNextTooltip}
|
||||
</affine-tooltip>`
|
||||
: nothing
|
||||
)}
|
||||
</icon-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._toolbarProvider.setValue(this);
|
||||
this._resizeObserver = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
const { width } = entry.contentRect;
|
||||
this._onContainerResize({ w: width });
|
||||
}
|
||||
});
|
||||
this._resizeObserver.observe(this);
|
||||
this.disposables.add(
|
||||
this.std
|
||||
.get(ThemeProvider)
|
||||
.theme$.subscribe(mode => this._themeProvider.setValue(mode))
|
||||
);
|
||||
if (!this.block) {
|
||||
return;
|
||||
}
|
||||
this._disposables.add(
|
||||
this.block.bindHotKey(
|
||||
{
|
||||
Escape: () => {
|
||||
if (this.gfx.selection.editing) return;
|
||||
if (this.edgelessTool.type === 'frameNavigator') return;
|
||||
if (this.edgelessTool.type === 'default') {
|
||||
if (this.activePopper) {
|
||||
this.activePopper.dispose();
|
||||
this.activePopper = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// @ts-expect-error FIXME: resolve after gfx tool refactor
|
||||
this.gfx.tool.setTool('default');
|
||||
},
|
||||
},
|
||||
{ global: true }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { _disposables, block, gfx } = this;
|
||||
if (!block) return;
|
||||
|
||||
const slots = this.std.get(EdgelessLegacySlotIdentifier);
|
||||
const editPropsStore = this.std.get(EditPropsStore);
|
||||
|
||||
_disposables.add(
|
||||
gfx.viewport.viewportUpdated.subscribe(() => this.requestUpdate())
|
||||
);
|
||||
_disposables.add(
|
||||
slots.readonlyUpdated.subscribe(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
_disposables.add(
|
||||
slots.toolbarLocked.subscribe(disabled => {
|
||||
this.toggleAttribute('disabled', disabled);
|
||||
})
|
||||
);
|
||||
// This state from `editPropsStore` is not reactive,
|
||||
// if the value is updated outside of this component, it will not be reflected.
|
||||
_disposables.add(
|
||||
editPropsStore.slots.storageUpdated.subscribe(({ key }) => {
|
||||
if (key === 'presentHideToolbar') {
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { type } = this.edgelessTool || {};
|
||||
if (this.doc.readonly && type !== 'frameNavigator') {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="edgeless-toolbar-wrapper"
|
||||
data-app-theme=${this._appTheme$.value}
|
||||
>
|
||||
<div
|
||||
class="edgeless-toolbar-toggle-control"
|
||||
data-enable=${this._enableAutoHide}
|
||||
>
|
||||
<smooth-corner
|
||||
class="edgeless-toolbar-smooth-corner"
|
||||
.borderRadius=${16}
|
||||
.smooth=${0.7}
|
||||
.borderWidth=${1}
|
||||
.bgColor=${'var(--affine-background-overlay-panel-color)'}
|
||||
.borderColor=${'var(--affine-border-color)'}
|
||||
style="filter: drop-shadow(${cssVar('toolbarShadow')})"
|
||||
>
|
||||
<div
|
||||
class="edgeless-toolbar-container"
|
||||
data-dense-quick=${this._denseQuickTools &&
|
||||
this._hiddenQuickTools.length > 0}
|
||||
data-dense-senior=${this._denseSeniorTools}
|
||||
@dblclick=${stopPropagation}
|
||||
@mousedown=${stopPropagation}
|
||||
@pointerdown=${stopPropagation}
|
||||
>
|
||||
${this.isPresentMode
|
||||
? html`<presentation-toolbar
|
||||
.edgeless=${this.block}
|
||||
.settingMenuShow=${this.presentSettingMenuShow}
|
||||
.frameMenuShow=${this.presentFrameMenuShow}
|
||||
.setSettingMenuShow=${(show: boolean) =>
|
||||
(this.presentSettingMenuShow = show)}
|
||||
.setFrameMenuShow=${(show: boolean) =>
|
||||
(this.presentFrameMenuShow = show)}
|
||||
.containerWidth=${this.containerWidth}
|
||||
></presentation-toolbar>`
|
||||
: nothing}
|
||||
${this.isPresentMode ? nothing : this._renderContent()}
|
||||
</div>
|
||||
</smooth-corner>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor presentFrameMenuShow = false;
|
||||
|
||||
@state()
|
||||
accessor presentSettingMenuShow = false;
|
||||
|
||||
@state()
|
||||
accessor scrollSeniorToolIndex = 0;
|
||||
|
||||
@query('.edgeless-toolbar-container')
|
||||
accessor toolbarContainer!: HTMLElement;
|
||||
}
|
||||
|
||||
export const edgelessToolbarWidget = WidgetViewExtension(
|
||||
'affine:page',
|
||||
EDGELESS_TOOLBAR_WIDGET,
|
||||
literal`${unsafeStatic(EDGELESS_TOOLBAR_WIDGET)}`
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { EdgelessToolIconButton } from './button/tool-icon-button';
|
||||
import { EdgelessToolbarButton } from './button/toolbar-button';
|
||||
import {
|
||||
EDGELESS_TOOLBAR_WIDGET,
|
||||
EdgelessToolbarWidget,
|
||||
} from './edgeless-toolbar';
|
||||
import { EdgelessFontFamilyPanel } from './panel/font-family-panel';
|
||||
import { EdgelessFontWeightAndStylePanel } from './panel/font-weight-and-style-panel';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(EDGELESS_TOOLBAR_WIDGET, EdgelessToolbarWidget);
|
||||
customElements.define('edgeless-toolbar-button', EdgelessToolbarButton);
|
||||
customElements.define('edgeless-tool-icon-button', EdgelessToolIconButton);
|
||||
customElements.define(
|
||||
'edgeless-font-weight-and-style-panel',
|
||||
EdgelessFontWeightAndStylePanel
|
||||
);
|
||||
customElements.define('edgeless-font-family-panel', EdgelessFontFamilyPanel);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-tool-icon-button': EdgelessToolIconButton;
|
||||
'edgeless-toolbar-button': EdgelessToolbarButton;
|
||||
'edgeless-toolbar-widget': EdgelessToolbarWidget;
|
||||
'edgeless-font-weight-and-style-panel': EdgelessFontWeightAndStylePanel;
|
||||
'edgeless-font-family-panel': EdgelessFontFamilyPanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { MenuConfig } from '@blocksuite/affine-components/context-menu';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import type { GfxController, GfxToolsMap } from '@blocksuite/std/gfx';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { type TemplateResult } from 'lit';
|
||||
|
||||
export interface QuickTool {
|
||||
type?: keyof GfxToolsMap;
|
||||
enable?: boolean;
|
||||
content: TemplateResult;
|
||||
/**
|
||||
* if not configured, the tool will not be shown in dense mode
|
||||
*/
|
||||
menu?: MenuConfig;
|
||||
}
|
||||
|
||||
export interface SeniorTool {
|
||||
/**
|
||||
* Used to show in nav-button's tooltip
|
||||
*/
|
||||
name: string;
|
||||
content: TemplateResult;
|
||||
enable?: boolean;
|
||||
}
|
||||
|
||||
export type ToolBuilder<T> = (options: {
|
||||
block: BlockComponent;
|
||||
gfx: GfxController;
|
||||
toolbarContainer: HTMLElement;
|
||||
}) => T;
|
||||
|
||||
export const QuickToolIdentifier = createIdentifier<ToolBuilder<QuickTool>>(
|
||||
'edgeless-quick-tool'
|
||||
);
|
||||
export const SeniorToolIdentifier = createIdentifier<ToolBuilder<SeniorTool>>(
|
||||
'edgeless-senior-tool'
|
||||
);
|
||||
|
||||
export const QuickToolExtension = (
|
||||
id: string,
|
||||
builder: ToolBuilder<QuickTool>
|
||||
): ExtensionType => {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(QuickToolIdentifier(id), () => builder);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const SeniorToolExtension = (
|
||||
id: string,
|
||||
builder: ToolBuilder<SeniorTool>
|
||||
): ExtensionType => {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(SeniorToolIdentifier(id), () => builder);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './config';
|
||||
export * from './context';
|
||||
export * from './create-popper';
|
||||
export * from './draggable';
|
||||
export * from './edgeless-toolbar';
|
||||
export * from './extension';
|
||||
export * from './mixins';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './quick-tool.mixin';
|
||||
export * from './tool.mixin';
|
||||
export * from './toolbar-button-with-menu.mixin';
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Constructor } from '@blocksuite/global/utils';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import {
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
type EdgelessToolbarToolClass,
|
||||
EdgelessToolbarToolMixin,
|
||||
} from './tool.mixin.js';
|
||||
|
||||
export declare abstract class QuickToolMixinClass extends EdgelessToolbarToolClass {}
|
||||
|
||||
/**
|
||||
* Mixin for quick tool item.
|
||||
*/
|
||||
export const QuickToolMixin = <T extends Constructor<LitElement>>(
|
||||
SuperClass: T
|
||||
) => {
|
||||
abstract class DerivedClass extends EdgelessToolbarToolMixin(SuperClass) {}
|
||||
|
||||
return DerivedClass as unknown as T & Constructor<QuickToolMixinClass>;
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { ColorScheme } from '@blocksuite/affine-model';
|
||||
import {
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
type DisposableClass,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/lit';
|
||||
import type { Constructor } from '@blocksuite/global/utils';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import {
|
||||
type GfxController,
|
||||
GfxControllerIdentifier,
|
||||
type GfxToolsFullOption,
|
||||
type GfxToolsFullOptionValue,
|
||||
type ToolController,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { consume } from '@lit/context';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import type { LitElement } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
|
||||
import {
|
||||
edgelessToolbarContext,
|
||||
type EdgelessToolbarSlots,
|
||||
edgelessToolbarSlotsContext,
|
||||
edgelessToolbarThemeContext,
|
||||
} from '../context';
|
||||
import { createPopper, type MenuPopper } from '../create-popper';
|
||||
import type { EdgelessToolbarWidget } from '../edgeless-toolbar';
|
||||
|
||||
type ValueOf<T> = T[keyof T];
|
||||
|
||||
export declare abstract class EdgelessToolbarToolClass extends DisposableClass {
|
||||
active: boolean;
|
||||
|
||||
createPopper: typeof createPopper;
|
||||
|
||||
edgeless: BlockComponent;
|
||||
|
||||
edgelessTool: GfxToolsFullOptionValue;
|
||||
|
||||
enableActiveBackground?: boolean;
|
||||
|
||||
popper: MenuPopper<HTMLElement> | null;
|
||||
|
||||
setEdgelessTool: ToolController['setTool'];
|
||||
|
||||
gfx: GfxController;
|
||||
|
||||
theme: ColorScheme;
|
||||
|
||||
toolbarContainer: HTMLElement | null;
|
||||
|
||||
toolbarSlots: EdgelessToolbarSlots;
|
||||
|
||||
/**
|
||||
* @return true if operation was successful
|
||||
*/
|
||||
tryDisposePopper: () => boolean;
|
||||
|
||||
abstract type:
|
||||
| GfxToolsFullOptionValue['type']
|
||||
| GfxToolsFullOptionValue['type'][];
|
||||
|
||||
accessor toolbar: EdgelessToolbarWidget;
|
||||
}
|
||||
|
||||
export const EdgelessToolbarToolMixin = <T extends Constructor<LitElement>>(
|
||||
SuperClass: T
|
||||
) => {
|
||||
abstract class DerivedClass extends WithDisposable(SuperClass) {
|
||||
enableActiveBackground = false;
|
||||
|
||||
abstract type:
|
||||
| GfxToolsFullOptionValue['type']
|
||||
| GfxToolsFullOptionValue['type'][];
|
||||
|
||||
get active() {
|
||||
const { type } = this;
|
||||
// @ts-expect-error FIXME: we need to fix the type of edgelessTool
|
||||
const activeType = this.edgelessTool?.type;
|
||||
|
||||
return activeType
|
||||
? Array.isArray(type)
|
||||
? // @ts-expect-error FIXME: we need to fix the type of edgelessTool
|
||||
type.includes(activeType)
|
||||
: activeType === type
|
||||
: false;
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.edgeless.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get setEdgelessTool() {
|
||||
return (...args: Parameters<ToolController['setTool']>) => {
|
||||
this.gfx.tool.setTool(
|
||||
// @ts-expect-error FIXME: ts error
|
||||
...args
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private _applyActiveStyle() {
|
||||
if (!this.enableActiveBackground) return;
|
||||
this.style.background = this.active
|
||||
? cssVar('hoverColor')
|
||||
: 'transparent';
|
||||
}
|
||||
|
||||
private _updateActiveEdgelessTool() {
|
||||
this.edgelessTool = this.gfx.tool.currentToolOption$.value;
|
||||
this._applyActiveStyle();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (!this.edgeless) return;
|
||||
this._updateActiveEdgelessTool();
|
||||
this._applyActiveStyle();
|
||||
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
this._updateActiveEdgelessTool();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: move to toolbar-tool-with-menu.mixin
|
||||
createPopper(...args: Parameters<typeof createPopper>) {
|
||||
if (this.toolbar.activePopper) {
|
||||
this.toolbar.activePopper.dispose();
|
||||
this.toolbar.activePopper = null;
|
||||
}
|
||||
this.popper = createPopper(args[0], args[1], {
|
||||
...args[2],
|
||||
onDispose: () => {
|
||||
args[2]?.onDispose?.();
|
||||
this.popper = null;
|
||||
},
|
||||
}) as MenuPopper<HTMLElement>;
|
||||
this.toolbar.activePopper = this.popper;
|
||||
return this.popper;
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.popper?.dispose();
|
||||
}
|
||||
|
||||
tryDisposePopper() {
|
||||
if (!this.active) return false;
|
||||
if (this.popper) {
|
||||
this.popper.dispose();
|
||||
this.popper = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: BlockComponent;
|
||||
|
||||
@state()
|
||||
accessor edgelessTool!: ValueOf<GfxToolsFullOption> | null;
|
||||
|
||||
@state()
|
||||
public accessor popper: MenuPopper<HTMLElement> | null = null;
|
||||
|
||||
@consume({ context: edgelessToolbarThemeContext, subscribe: true })
|
||||
accessor theme!: ColorScheme;
|
||||
|
||||
@consume({ context: edgelessToolbarContext })
|
||||
accessor toolbar!: EdgelessToolbarWidget;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor toolbarContainer: HTMLElement | null = null;
|
||||
|
||||
@consume({ context: edgelessToolbarSlotsContext })
|
||||
accessor toolbarSlots!: EdgelessToolbarSlots;
|
||||
}
|
||||
|
||||
return DerivedClass as unknown as T & Constructor<EdgelessToolbarToolClass>;
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import type { Constructor } from '@blocksuite/global/utils';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import {
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
type EdgelessToolbarToolClass,
|
||||
EdgelessToolbarToolMixin,
|
||||
} from './tool.mixin.js';
|
||||
|
||||
export declare abstract class ToolbarButtonWithMenuClass extends EdgelessToolbarToolClass {}
|
||||
|
||||
export const ToolbarButtonWithMenuMixin = <
|
||||
T extends Constructor<LitElement> = Constructor<LitElement>,
|
||||
>(
|
||||
SuperClass: T
|
||||
) => {
|
||||
abstract class DerivedClass extends EdgelessToolbarToolMixin(SuperClass) {}
|
||||
|
||||
return DerivedClass as unknown as T & Constructor<ToolbarButtonWithMenuClass>;
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { TextUtils } from '@blocksuite/affine-block-surface';
|
||||
import { FontFamily, FontFamilyList } from '@blocksuite/affine-model';
|
||||
import { DoneIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
export class EdgelessFontFamilyPanel extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
min-width: 136px;
|
||||
}
|
||||
|
||||
edgeless-tool-icon-button {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
private _onSelect(value: FontFamily) {
|
||||
this.value = value;
|
||||
if (this.onSelect) {
|
||||
this.onSelect(value);
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return repeat(
|
||||
FontFamilyList,
|
||||
item => item[0],
|
||||
([font, name]) => {
|
||||
const active = this.value === font;
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
data-font="${name}"
|
||||
style="font-family: ${TextUtils.wrapFontFamily(font)}"
|
||||
.iconContainerPadding=${[4, 8]}
|
||||
.justify=${'space-between'}
|
||||
.active=${active}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() => this._onSelect(font)}
|
||||
>
|
||||
${name} ${active ? DoneIcon() : nothing}
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSelect: ((value: FontFamily) => void) | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor value: FontFamily = FontFamily.Inter;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { TextUtils } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
FontFamily,
|
||||
FontFamilyMap,
|
||||
FontStyle,
|
||||
FontWeight,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { DoneIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property } 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';
|
||||
|
||||
const FONT_WEIGHT_CHOOSE: [FontWeight, () => string][] = [
|
||||
[FontWeight.Light, () => 'Light'],
|
||||
[FontWeight.Regular, () => 'Regular'],
|
||||
[FontWeight.SemiBold, () => 'Semibold'],
|
||||
];
|
||||
|
||||
export class EdgelessFontWeightAndStylePanel extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
min-width: 124px;
|
||||
}
|
||||
|
||||
edgeless-tool-icon-button {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
private _isActive(
|
||||
fontWeight: FontWeight,
|
||||
fontStyle: FontStyle = FontStyle.Normal
|
||||
) {
|
||||
return this.fontWeight === fontWeight && this.fontStyle === fontStyle;
|
||||
}
|
||||
|
||||
private _isDisabled(
|
||||
fontWeight: FontWeight,
|
||||
fontStyle: FontStyle = FontStyle.Normal
|
||||
) {
|
||||
// Compatible with old data
|
||||
if (!(this.fontFamily in FontFamilyMap)) return false;
|
||||
|
||||
const fontFace = TextUtils.getFontFaces()
|
||||
.filter(TextUtils.isSameFontFamily(this.fontFamily))
|
||||
.find(
|
||||
fontFace =>
|
||||
fontFace.weight === fontWeight && fontFace.style === fontStyle
|
||||
);
|
||||
|
||||
return !fontFace;
|
||||
}
|
||||
|
||||
private _onSelect(
|
||||
fontWeight: FontWeight,
|
||||
fontStyle: FontStyle = FontStyle.Normal
|
||||
) {
|
||||
this.fontWeight = fontWeight;
|
||||
this.fontStyle = fontStyle;
|
||||
if (this.onSelect) {
|
||||
this.onSelect(fontWeight, fontStyle);
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
let fontFaces = TextUtils.getFontFacesByFontFamily(this.fontFamily);
|
||||
// Compatible with old data
|
||||
if (fontFaces.length === 0) {
|
||||
fontFaces = TextUtils.getFontFacesByFontFamily(FontFamily.Inter);
|
||||
}
|
||||
const fontFacesWithNormal = fontFaces.filter(
|
||||
fontFace => fontFace.style === FontStyle.Normal
|
||||
);
|
||||
const fontFacesWithItalic = fontFaces.filter(
|
||||
fontFace => fontFace.style === FontStyle.Italic
|
||||
);
|
||||
|
||||
return join(
|
||||
[
|
||||
fontFacesWithNormal.length > 0
|
||||
? repeat(
|
||||
fontFacesWithNormal,
|
||||
fontFace => fontFace.weight,
|
||||
fontFace => {
|
||||
const active = this._isActive(fontFace.weight as FontWeight);
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
data-weight="${fontFace.weight}"
|
||||
.iconContainerPadding=${[4, 8]}
|
||||
.justify=${'space-between'}
|
||||
.disabled=${this._isDisabled(fontFace.weight as FontWeight)}
|
||||
.active=${active}
|
||||
.iconSize=${'20px'}
|
||||
@click=${() =>
|
||||
this._onSelect(fontFace.weight as FontWeight)}
|
||||
>
|
||||
${choose(fontFace.weight, FONT_WEIGHT_CHOOSE)}
|
||||
${active ? DoneIcon() : nothing}
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
)
|
||||
: nothing,
|
||||
fontFacesWithItalic.length > 0
|
||||
? repeat(
|
||||
fontFacesWithItalic,
|
||||
fontFace => fontFace.weight,
|
||||
fontFace => {
|
||||
const active = this._isActive(
|
||||
fontFace.weight as FontWeight,
|
||||
FontStyle.Italic
|
||||
);
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
data-weight="${fontFace.weight} italic"
|
||||
.iconContainerPadding=${[4, 8]}
|
||||
.justify=${'space-between'}
|
||||
.disabled=${this._isDisabled(
|
||||
fontFace.weight as FontWeight,
|
||||
FontStyle.Italic
|
||||
)}
|
||||
.active=${active}
|
||||
@click=${() =>
|
||||
this._onSelect(
|
||||
fontFace.weight as FontWeight,
|
||||
FontStyle.Italic
|
||||
)}
|
||||
>
|
||||
${choose(fontFace.weight, FONT_WEIGHT_CHOOSE)} Italic
|
||||
${active ? DoneIcon() : nothing}
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
)
|
||||
: nothing,
|
||||
].filter(item => item !== nothing),
|
||||
() => html`
|
||||
<edgeless-menu-divider
|
||||
data-orientation="horizontal"
|
||||
></edgeless-menu-divider>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor fontFamily = FontFamily.Inter;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor fontStyle = FontStyle.Normal;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor fontWeight = FontWeight.Regular;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSelect:
|
||||
| ((fontWeight: FontWeight, fontStyle: FontStyle) => void)
|
||||
| undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user