mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 01:42:55 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
1
blocksuite/blocks/src/root-block/edgeless/block-model.ts
Normal file
1
blocksuite/blocks/src/root-block/edgeless/block-model.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { GfxBlockElementModel as GfxBlockModel } from '@blocksuite/block-std/gfx';
|
||||
1452
blocksuite/blocks/src/root-block/edgeless/clipboard/clipboard.ts
Normal file
1452
blocksuite/blocks/src/root-block/edgeless/clipboard/clipboard.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,660 @@
|
||||
import {
|
||||
CanvasElementType,
|
||||
CommonUtils,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
FontFamilyIcon,
|
||||
FrameIcon,
|
||||
SmallNoteIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import type {
|
||||
Connection,
|
||||
ConnectorElementModel,
|
||||
ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
DEFAULT_NOTE_BACKGROUND_COLOR,
|
||||
DEFAULT_NOTE_WIDTH,
|
||||
DEFAULT_SHAPE_FILL_COLOR,
|
||||
DEFAULT_SHAPE_STROKE_COLOR,
|
||||
DEFAULT_TEXT_COLOR,
|
||||
FontFamily,
|
||||
FontStyle,
|
||||
FontWeight,
|
||||
getShapeName,
|
||||
GroupElementModel,
|
||||
NoteBlockModel,
|
||||
ShapeStyle,
|
||||
TextElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditPropsStore,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { captureEventTarget } from '@blocksuite/affine-shared/utils';
|
||||
import { type BlockStdScope, stdContext } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import type { XYWH } from '@blocksuite/global/utils';
|
||||
import {
|
||||
assertInstanceOf,
|
||||
Bound,
|
||||
serializeXYWH,
|
||||
Vec,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { DocCollection } from '@blocksuite/store';
|
||||
import { consume } from '@lit/context';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
import {
|
||||
SHAPE_OVERLAY_HEIGHT,
|
||||
SHAPE_OVERLAY_WIDTH,
|
||||
} from '../../utils/consts.js';
|
||||
import {
|
||||
mountShapeTextEditor,
|
||||
mountTextElementEditor,
|
||||
} from '../../utils/text.js';
|
||||
import { ShapeComponentConfig } from '../toolbar/shape/shape-menu-config.js';
|
||||
import {
|
||||
type AUTO_COMPLETE_TARGET_TYPE,
|
||||
AutoCompleteFrameOverlay,
|
||||
AutoCompleteNoteOverlay,
|
||||
AutoCompleteShapeOverlay,
|
||||
AutoCompleteTextOverlay,
|
||||
capitalizeFirstLetter,
|
||||
createShapeElement,
|
||||
DEFAULT_NOTE_OVERLAY_HEIGHT,
|
||||
DEFAULT_TEXT_HEIGHT,
|
||||
DEFAULT_TEXT_WIDTH,
|
||||
Direction,
|
||||
isShape,
|
||||
PANEL_HEIGHT,
|
||||
PANEL_WIDTH,
|
||||
type TARGET_SHAPE_TYPE,
|
||||
} from './utils.js';
|
||||
|
||||
export class EdgelessAutoCompletePanel extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.auto-complete-panel-container {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
width: 136px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
gap: 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.row-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 120px;
|
||||
height: 28px;
|
||||
padding: 4px 0;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--affine-border-color, #e3e2e4);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
||||
|
||||
private _overlay:
|
||||
| AutoCompleteShapeOverlay
|
||||
| AutoCompleteNoteOverlay
|
||||
| AutoCompleteFrameOverlay
|
||||
| AutoCompleteTextOverlay
|
||||
| null = null;
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
constructor(
|
||||
position: [number, number],
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
currentSource: ShapeElementModel | NoteBlockModel,
|
||||
connector: ConnectorElementModel
|
||||
) {
|
||||
super();
|
||||
this.position = position;
|
||||
this.edgeless = edgeless;
|
||||
this.currentSource = currentSource;
|
||||
this.connector = connector;
|
||||
}
|
||||
|
||||
private _addFrame() {
|
||||
const bound = this._generateTarget(this.connector)?.nextBound;
|
||||
if (!bound) return;
|
||||
|
||||
const { h } = bound;
|
||||
const w = h / 0.75;
|
||||
const target = this._getTargetXYWH(w, h);
|
||||
if (!target) return;
|
||||
|
||||
const { xywh, position } = target;
|
||||
|
||||
const edgeless = this.edgeless;
|
||||
const { service, surfaceBlockModel } = edgeless;
|
||||
const frameMgr = service.frame;
|
||||
const frameIndex = service.frames.length + 1;
|
||||
const id = service.addBlock(
|
||||
'affine:frame',
|
||||
{
|
||||
title: new DocCollection.Y.Text(`Frame ${frameIndex}`),
|
||||
xywh: serializeXYWH(...xywh),
|
||||
presentationIndex: frameMgr.generatePresentationIndex(),
|
||||
},
|
||||
surfaceBlockModel
|
||||
);
|
||||
edgeless.doc.captureSync();
|
||||
const frame = service.getElementById(id);
|
||||
if (!frame) return;
|
||||
|
||||
this.connector.target = {
|
||||
id,
|
||||
position,
|
||||
};
|
||||
|
||||
edgeless.service.selection.set({
|
||||
elements: [frame.id],
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
private _addNote() {
|
||||
const { doc } = this.edgeless;
|
||||
const service = this.edgeless.service;
|
||||
const target = this._getTargetXYWH(
|
||||
DEFAULT_NOTE_WIDTH,
|
||||
DEFAULT_NOTE_OVERLAY_HEIGHT
|
||||
);
|
||||
if (!target) return;
|
||||
|
||||
const { xywh, position } = target;
|
||||
const id = service.addBlock(
|
||||
'affine:note',
|
||||
{
|
||||
xywh: serializeXYWH(...xywh),
|
||||
},
|
||||
doc.root?.id
|
||||
);
|
||||
const note = doc.getBlock(id)?.model;
|
||||
assertInstanceOf(note, NoteBlockModel);
|
||||
doc.addBlock('affine:paragraph', { type: 'text' }, id);
|
||||
const group = this.currentSource.group;
|
||||
|
||||
if (group instanceof GroupElementModel) {
|
||||
group.addChild(note);
|
||||
}
|
||||
this.connector.target = {
|
||||
id,
|
||||
position: position as [number, number],
|
||||
};
|
||||
service.updateElement(this.connector.id, {
|
||||
target: { id, position },
|
||||
});
|
||||
this.edgeless.service.selection.set({
|
||||
elements: [id],
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
private _addShape(targetType: TARGET_SHAPE_TYPE) {
|
||||
const edgeless = this.edgeless;
|
||||
const result = this._generateTarget(this.connector);
|
||||
if (!result) return;
|
||||
|
||||
const currentSource = this.currentSource;
|
||||
const { nextBound, position } = result;
|
||||
const { service } = edgeless;
|
||||
const id = createShapeElement(edgeless, currentSource, targetType);
|
||||
|
||||
service.updateElement(id, { xywh: nextBound.serialize() });
|
||||
service.updateElement(this.connector.id, {
|
||||
target: { id, position },
|
||||
});
|
||||
|
||||
mountShapeTextEditor(
|
||||
service.getElementById(id) as ShapeElementModel,
|
||||
this.edgeless
|
||||
);
|
||||
edgeless.service.selection.set({
|
||||
elements: [id],
|
||||
editing: true,
|
||||
});
|
||||
edgeless.doc.captureSync();
|
||||
}
|
||||
|
||||
private _addText() {
|
||||
const target = this._getTargetXYWH(DEFAULT_TEXT_WIDTH, DEFAULT_TEXT_HEIGHT);
|
||||
if (!target) return;
|
||||
const { xywh, position } = target;
|
||||
const bound = Bound.fromXYWH(xywh);
|
||||
const edgelessService = this.edgeless.service;
|
||||
|
||||
const textFlag = this.edgeless.doc.awarenessStore.getFlag(
|
||||
'enable_edgeless_text'
|
||||
);
|
||||
if (textFlag) {
|
||||
const { textId } = this.edgeless.std.command.exec('insertEdgelessText', {
|
||||
x: bound.x,
|
||||
y: bound.y,
|
||||
});
|
||||
if (!textId) return;
|
||||
|
||||
const textElement = edgelessService.getElementById(textId);
|
||||
if (!textElement) return;
|
||||
|
||||
edgelessService.updateElement(this.connector.id, {
|
||||
target: { id: textId, position },
|
||||
});
|
||||
if (this.currentSource.group instanceof GroupElementModel) {
|
||||
this.currentSource.group.addChild(textElement);
|
||||
}
|
||||
|
||||
this.edgeless.service.selection.set({
|
||||
elements: [textId],
|
||||
editing: false,
|
||||
});
|
||||
this.edgeless.doc.captureSync();
|
||||
} else {
|
||||
const textId = edgelessService.addElement(CanvasElementType.TEXT, {
|
||||
xywh: bound.serialize(),
|
||||
text: new DocCollection.Y.Text(),
|
||||
textAlign: 'left',
|
||||
fontSize: 24,
|
||||
fontFamily: FontFamily.Inter,
|
||||
color: DEFAULT_TEXT_COLOR,
|
||||
fontWeight: FontWeight.Regular,
|
||||
fontStyle: FontStyle.Normal,
|
||||
});
|
||||
const textElement = edgelessService.getElementById(textId);
|
||||
assertInstanceOf(textElement, TextElementModel);
|
||||
|
||||
edgelessService.updateElement(this.connector.id, {
|
||||
target: { id: textId, position },
|
||||
});
|
||||
if (this.currentSource.group instanceof GroupElementModel) {
|
||||
this.currentSource.group.addChild(textElement);
|
||||
}
|
||||
|
||||
this.edgeless.service.selection.set({
|
||||
elements: [textId],
|
||||
editing: false,
|
||||
});
|
||||
this.edgeless.doc.captureSync();
|
||||
|
||||
mountTextElementEditor(textElement, this.edgeless);
|
||||
}
|
||||
}
|
||||
|
||||
private _autoComplete(targetType: AUTO_COMPLETE_TARGET_TYPE) {
|
||||
this._removeOverlay();
|
||||
if (!this._connectorExist()) return;
|
||||
|
||||
switch (targetType) {
|
||||
case 'text':
|
||||
this._addText();
|
||||
break;
|
||||
case 'note':
|
||||
this._addNote();
|
||||
break;
|
||||
case 'frame':
|
||||
this._addFrame();
|
||||
break;
|
||||
default:
|
||||
this._addShape(targetType);
|
||||
}
|
||||
|
||||
this.remove();
|
||||
}
|
||||
|
||||
private _connectorExist() {
|
||||
return !!this.edgeless.service.getElementById(this.connector.id);
|
||||
}
|
||||
|
||||
private _generateTarget(connector: ConnectorElementModel) {
|
||||
const { currentSource } = this;
|
||||
let w = SHAPE_OVERLAY_WIDTH;
|
||||
let h = SHAPE_OVERLAY_HEIGHT;
|
||||
if (isShape(currentSource)) {
|
||||
const bound = Bound.deserialize(currentSource.xywh);
|
||||
w = bound.w;
|
||||
h = bound.h;
|
||||
}
|
||||
const point = connector.target.position;
|
||||
if (!point) return;
|
||||
|
||||
const len = connector.path.length;
|
||||
const angle = CommonUtils.normalizeDegAngle(
|
||||
CommonUtils.toDegree(
|
||||
Vec.angle(connector.path[len - 2], connector.path[len - 1])
|
||||
)
|
||||
);
|
||||
let nextBound: Bound;
|
||||
let position: Connection['position'];
|
||||
// direction of the connector target arrow
|
||||
let direction: Direction;
|
||||
|
||||
if (angle >= 45 && angle <= 135) {
|
||||
nextBound = new Bound(point[0] - w / 2, point[1], w, h);
|
||||
position = [0.5, 0];
|
||||
direction = Direction.Bottom;
|
||||
} else if (angle >= 135 && angle <= 225) {
|
||||
nextBound = new Bound(point[0] - w, point[1] - h / 2, w, h);
|
||||
position = [1, 0.5];
|
||||
direction = Direction.Left;
|
||||
} else if (angle >= 225 && angle <= 315) {
|
||||
nextBound = new Bound(point[0] - w / 2, point[1] - h, w, h);
|
||||
position = [0.5, 1];
|
||||
direction = Direction.Top;
|
||||
} else {
|
||||
nextBound = new Bound(point[0], point[1] - h / 2, w, h);
|
||||
position = [0, 0.5];
|
||||
direction = Direction.Right;
|
||||
}
|
||||
|
||||
return { nextBound, position, direction };
|
||||
}
|
||||
|
||||
private _getCurrentSourceInfo(): {
|
||||
style: ShapeStyle;
|
||||
type: AUTO_COMPLETE_TARGET_TYPE;
|
||||
} {
|
||||
const { currentSource } = this;
|
||||
if (isShape(currentSource)) {
|
||||
const { shapeType, shapeStyle, radius } = currentSource;
|
||||
return {
|
||||
style: shapeStyle,
|
||||
type: getShapeName(shapeType, radius),
|
||||
};
|
||||
}
|
||||
return {
|
||||
style: ShapeStyle.General,
|
||||
type: 'note',
|
||||
};
|
||||
}
|
||||
|
||||
private _getPanelPosition() {
|
||||
const { viewport } = this.edgeless.service;
|
||||
const { boundingClientRect: viewportRect, zoom } = viewport;
|
||||
const result = this._getTargetXYWH(PANEL_WIDTH / zoom, PANEL_HEIGHT / zoom);
|
||||
const pos = result ? result.xywh.slice(0, 2) : this.position;
|
||||
const coord = viewport.toViewCoord(pos[0], pos[1]);
|
||||
const { width, height } = viewportRect;
|
||||
|
||||
coord[0] = CommonUtils.clamp(coord[0], 20, width - 20 - PANEL_WIDTH);
|
||||
coord[1] = CommonUtils.clamp(coord[1], 20, height - 20 - PANEL_HEIGHT);
|
||||
|
||||
return coord;
|
||||
}
|
||||
|
||||
private _getTargetXYWH(width: number, height: number) {
|
||||
const result = this._generateTarget(this.connector);
|
||||
if (!result) return null;
|
||||
|
||||
const { nextBound: bound, direction, position } = result;
|
||||
if (!bound) return null;
|
||||
|
||||
const { w, h } = bound;
|
||||
let x = bound.x;
|
||||
let y = bound.y;
|
||||
|
||||
switch (direction) {
|
||||
case Direction.Right:
|
||||
y += h / 2 - height / 2;
|
||||
break;
|
||||
case Direction.Bottom:
|
||||
x -= width / 2 - w / 2;
|
||||
break;
|
||||
case Direction.Left:
|
||||
y += h / 2 - height / 2;
|
||||
x -= width - w;
|
||||
break;
|
||||
case Direction.Top:
|
||||
x -= width / 2 - w / 2;
|
||||
y += h - height;
|
||||
break;
|
||||
}
|
||||
|
||||
const xywh = [x, y, width, height] as XYWH;
|
||||
|
||||
return { xywh, position };
|
||||
}
|
||||
|
||||
private _removeOverlay() {
|
||||
if (this._overlay)
|
||||
this.edgeless.surface.renderer.removeOverlay(this._overlay);
|
||||
}
|
||||
|
||||
private _showFrameOverlay() {
|
||||
const bound = this._generateTarget(this.connector)?.nextBound;
|
||||
if (!bound) return;
|
||||
|
||||
const { h } = bound;
|
||||
const w = h / 0.75;
|
||||
const xywh = this._getTargetXYWH(w, h)?.xywh;
|
||||
if (!xywh) return;
|
||||
|
||||
const strokeColor = this.std
|
||||
.get(ThemeProvider)
|
||||
.getCssVariableColor('--affine-black-30');
|
||||
this._overlay = new AutoCompleteFrameOverlay(this.gfx, xywh, strokeColor);
|
||||
this.edgeless.surface.renderer.addOverlay(this._overlay);
|
||||
}
|
||||
|
||||
private _showNoteOverlay() {
|
||||
const xywh = this._getTargetXYWH(
|
||||
DEFAULT_NOTE_WIDTH,
|
||||
DEFAULT_NOTE_OVERLAY_HEIGHT
|
||||
)?.xywh;
|
||||
if (!xywh) return;
|
||||
|
||||
const background = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.getColorValue(
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value['affine:note']
|
||||
.background,
|
||||
DEFAULT_NOTE_BACKGROUND_COLOR,
|
||||
true
|
||||
);
|
||||
this._overlay = new AutoCompleteNoteOverlay(this.gfx, xywh, background);
|
||||
this.edgeless.surface.renderer.addOverlay(this._overlay);
|
||||
}
|
||||
|
||||
private _showOverlay(targetType: AUTO_COMPLETE_TARGET_TYPE) {
|
||||
this._removeOverlay();
|
||||
if (!this._connectorExist()) return;
|
||||
|
||||
switch (targetType) {
|
||||
case 'text':
|
||||
this._showTextOverlay();
|
||||
break;
|
||||
case 'note':
|
||||
this._showNoteOverlay();
|
||||
break;
|
||||
case 'frame':
|
||||
this._showFrameOverlay();
|
||||
break;
|
||||
default:
|
||||
this._showShapeOverlay(targetType);
|
||||
}
|
||||
|
||||
this.edgeless.surface.refresh();
|
||||
}
|
||||
|
||||
private _showShapeOverlay(targetType: TARGET_SHAPE_TYPE) {
|
||||
const bound = this._generateTarget(this.connector)?.nextBound;
|
||||
if (!bound) return;
|
||||
|
||||
const { x, y, w, h } = bound;
|
||||
const xywh = [x, y, w, h] as XYWH;
|
||||
const { shapeStyle, strokeColor, fillColor, strokeWidth, roughness } =
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value[
|
||||
`shape:${targetType}`
|
||||
];
|
||||
|
||||
const stroke = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.getColorValue(strokeColor, DEFAULT_SHAPE_STROKE_COLOR, true);
|
||||
const fill = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.getColorValue(fillColor, DEFAULT_SHAPE_FILL_COLOR, true);
|
||||
|
||||
const options = {
|
||||
seed: 666,
|
||||
roughness: roughness,
|
||||
strokeLineDash: [0, 0],
|
||||
stroke,
|
||||
strokeWidth,
|
||||
fill,
|
||||
};
|
||||
|
||||
this._overlay = new AutoCompleteShapeOverlay(
|
||||
this.gfx,
|
||||
xywh,
|
||||
targetType,
|
||||
options,
|
||||
shapeStyle
|
||||
);
|
||||
|
||||
this.edgeless.surface.renderer.addOverlay(this._overlay);
|
||||
}
|
||||
|
||||
private _showTextOverlay() {
|
||||
const xywh = this._getTargetXYWH(
|
||||
DEFAULT_TEXT_WIDTH,
|
||||
DEFAULT_TEXT_HEIGHT
|
||||
)?.xywh;
|
||||
if (!xywh) return;
|
||||
|
||||
this._overlay = new AutoCompleteTextOverlay(this.gfx, xywh);
|
||||
this.edgeless.surface.renderer.addOverlay(this._overlay);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.edgeless.handleEvent('click', ctx => {
|
||||
const { target } = ctx.get('pointerState').raw;
|
||||
const element = captureEventTarget(target);
|
||||
const clickAway = !element?.closest('edgeless-auto-complete-panel');
|
||||
if (clickAway) this.remove();
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._removeOverlay();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this.disposables.add(
|
||||
this.edgeless.service.viewport.viewportUpdated.on(() =>
|
||||
this.requestUpdate()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const position = this._getPanelPosition();
|
||||
if (!position) return nothing;
|
||||
|
||||
const style = styleMap({
|
||||
left: `${position[0]}px`,
|
||||
top: `${position[1]}px`,
|
||||
});
|
||||
const { style: currentSourceStyle, type: currentSourceType } =
|
||||
this._getCurrentSourceInfo();
|
||||
|
||||
const shapeButtons = repeat(
|
||||
ShapeComponentConfig,
|
||||
({ name, generalIcon, scribbledIcon, tooltip }) => html`
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${tooltip}
|
||||
@pointerenter=${() => this._showOverlay(name)}
|
||||
@pointerleave=${() => this._removeOverlay()}
|
||||
@click=${() => this._autoComplete(name)}
|
||||
>
|
||||
${currentSourceStyle === 'General' ? generalIcon : scribbledIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
`
|
||||
);
|
||||
|
||||
return html`<div class="auto-complete-panel-container" style=${style}>
|
||||
${shapeButtons}
|
||||
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${'Text'}
|
||||
@pointerenter=${() => this._showOverlay('text')}
|
||||
@pointerleave=${() => this._removeOverlay()}
|
||||
@click=${() => this._autoComplete('text')}
|
||||
>
|
||||
${FontFamilyIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${'Note'}
|
||||
@pointerenter=${() => this._showOverlay('note')}
|
||||
@pointerleave=${() => this._removeOverlay()}
|
||||
@click=${() => this._autoComplete('note')}
|
||||
>
|
||||
${SmallNoteIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${'Frame'}
|
||||
@pointerenter=${() => this._showOverlay('frame')}
|
||||
@pointerleave=${() => this._removeOverlay()}
|
||||
@click=${() => this._autoComplete('frame')}
|
||||
>
|
||||
${FrameIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
|
||||
<edgeless-tool-icon-button
|
||||
.iconContainerPadding=${0}
|
||||
.tooltip=${capitalizeFirstLetter(currentSourceType)}
|
||||
@pointerenter=${() => this._showOverlay(currentSourceType)}
|
||||
@pointerleave=${() => this._removeOverlay()}
|
||||
@click=${() => this._autoComplete(currentSourceType)}
|
||||
>
|
||||
<div class="row-button">Add a same object</div>
|
||||
</edgeless-tool-icon-button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor connector: ConnectorElementModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor currentSource: ShapeElementModel | NoteBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor position: [number, number];
|
||||
|
||||
@consume({
|
||||
context: stdContext,
|
||||
})
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-auto-complete-panel': EdgelessAutoCompletePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
import {
|
||||
CanvasElementType,
|
||||
type ConnectionOverlay,
|
||||
ConnectorPathGenerator,
|
||||
Overlay,
|
||||
OverlayIdentifier,
|
||||
type RoughCanvas,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
AutoCompleteArrowIcon,
|
||||
MindMapChildIcon,
|
||||
MindMapSiblingIcon,
|
||||
NoteAutoCompleteIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import type {
|
||||
Connection,
|
||||
ConnectorElementModel,
|
||||
NoteBlockModel,
|
||||
ShapeType,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
DEFAULT_NOTE_HEIGHT,
|
||||
DEFAULT_SHAPE_STROKE_COLOR,
|
||||
LayoutType,
|
||||
MindmapElementModel,
|
||||
ShapeElementModel,
|
||||
shapeMethods,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { handleNativeRangeAtPoint } from '@blocksuite/affine-shared/utils';
|
||||
import { type BlockStdScope, stdContext } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import type { Bound, IVec } from '@blocksuite/global/utils';
|
||||
import {
|
||||
assertExists,
|
||||
DisposableGroup,
|
||||
Vec,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { consume } from '@lit/context';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
import { isNoteBlock } from '../../utils/query.js';
|
||||
import { mountShapeTextEditor } from '../../utils/text.js';
|
||||
import type { SelectedRect } from '../rects/edgeless-selected-rect.js';
|
||||
import { EdgelessAutoCompletePanel } from './auto-complete-panel.js';
|
||||
import {
|
||||
createEdgelessElement,
|
||||
Direction,
|
||||
getPosition,
|
||||
isShape,
|
||||
MAIN_GAP,
|
||||
nextBound,
|
||||
} from './utils.js';
|
||||
|
||||
class AutoCompleteOverlay extends Overlay {
|
||||
linePoints: IVec[] = [];
|
||||
|
||||
renderShape: ((ctx: CanvasRenderingContext2D) => void) | null = null;
|
||||
|
||||
stroke = '';
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
|
||||
if (this.linePoints.length && this.renderShape) {
|
||||
ctx.setLineDash([2, 2]);
|
||||
ctx.strokeStyle = this.stroke;
|
||||
ctx.beginPath();
|
||||
this.linePoints.forEach((p, index) => {
|
||||
if (index === 0) ctx.moveTo(p[0], p[1]);
|
||||
else ctx.lineTo(p[0], p[1]);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
this.renderShape(ctx);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class EdgelessAutoComplete extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.edgeless-auto-complete-container {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.edgeless-auto-complete-arrow-wrapper {
|
||||
width: 72px;
|
||||
height: 44px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.edgeless-auto-complete-arrow-wrapper.hidden {
|
||||
display: none;
|
||||
}
|
||||
.edgeless-auto-complete-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 19px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition:
|
||||
background 0.3s linear,
|
||||
box-shadow 0.2s linear;
|
||||
}
|
||||
.edgeless-auto-complete-arrow-wrapper.mindmap {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.edgeless-auto-complete-arrow-wrapper:hover
|
||||
> .edgeless-auto-complete-arrow {
|
||||
border: 1px solid var(--affine-border-color);
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
background: var(--affine-white);
|
||||
}
|
||||
|
||||
.edgeless-auto-complete-arrow-wrapper
|
||||
> .edgeless-auto-complete-arrow:hover {
|
||||
border: 1px solid var(--affine-white-10);
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
background: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.edgeless-auto-complete-arrow-wrapper.mindmap
|
||||
> .edgeless-auto-complete-arrow {
|
||||
border: 1px solid var(--affine-border-color);
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
background: var(--affine-white);
|
||||
|
||||
transition:
|
||||
background 0.3s linear,
|
||||
color 0.2s linear;
|
||||
}
|
||||
|
||||
.edgeless-auto-complete-arrow-wrapper.mindmap
|
||||
> .edgeless-auto-complete-arrow:hover {
|
||||
border: 1px solid var(--affine-white-10);
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
background: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.edgeless-auto-complete-arrow svg {
|
||||
fill: #77757d;
|
||||
color: #77757d;
|
||||
}
|
||||
.edgeless-auto-complete-arrow:hover svg {
|
||||
fill: #ffffff;
|
||||
color: #ffffff;
|
||||
}
|
||||
`;
|
||||
|
||||
private _autoCompleteOverlay!: AutoCompleteOverlay;
|
||||
|
||||
private _onPointerDown = (e: PointerEvent, type: Direction) => {
|
||||
const { service } = this.edgeless;
|
||||
const viewportRect = service.viewport.boundingClientRect;
|
||||
const start = service.viewport.toModelCoord(
|
||||
e.clientX - viewportRect.left,
|
||||
e.clientY - viewportRect.top
|
||||
);
|
||||
|
||||
if (!this.edgeless.dispatcher) return;
|
||||
|
||||
let connector: ConnectorElementModel | null;
|
||||
|
||||
this._disposables.addFromEvent(document, 'pointermove', e => {
|
||||
const point = service.viewport.toModelCoord(
|
||||
e.clientX - viewportRect.left,
|
||||
e.clientY - viewportRect.top
|
||||
);
|
||||
if (Vec.dist(start, point) > 8 && !this._isMoving) {
|
||||
if (!this.canShowAutoComplete) return;
|
||||
this._isMoving = true;
|
||||
const { startPosition } = getPosition(type);
|
||||
connector = this._addConnector(
|
||||
{
|
||||
id: this.current.id,
|
||||
position: startPosition,
|
||||
},
|
||||
{
|
||||
position: point,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (this._isMoving) {
|
||||
assertExists(connector);
|
||||
const otherSideId = connector.source.id;
|
||||
|
||||
connector.target = this.connectionOverlay.renderConnector(
|
||||
point,
|
||||
otherSideId ? [otherSideId] : []
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this._disposables.addFromEvent(document, 'pointerup', e => {
|
||||
if (!this._isMoving) {
|
||||
this._generateElementOnClick(type);
|
||||
} else if (connector && !connector.target.id) {
|
||||
this.edgeless.service.selection.clear();
|
||||
this._createAutoCompletePanel(e, connector);
|
||||
}
|
||||
|
||||
this._isMoving = false;
|
||||
this.connectionOverlay.clear();
|
||||
this._disposables.dispose();
|
||||
this._disposables = new DisposableGroup();
|
||||
});
|
||||
};
|
||||
|
||||
private _pathGenerator!: ConnectorPathGenerator;
|
||||
|
||||
private _timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
get canShowAutoComplete() {
|
||||
const { current } = this;
|
||||
return isShape(current) || isNoteBlock(current);
|
||||
}
|
||||
|
||||
get connectionOverlay() {
|
||||
return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay;
|
||||
}
|
||||
|
||||
private _addConnector(source: Connection, target: Connection) {
|
||||
const { edgeless } = this;
|
||||
const id = edgeless.service.addElement(CanvasElementType.CONNECTOR, {
|
||||
source,
|
||||
target,
|
||||
});
|
||||
return edgeless.service.getElementById(id) as ConnectorElementModel;
|
||||
}
|
||||
|
||||
private _addMindmapNode(target: 'sibling' | 'child') {
|
||||
const mindmap = this.current.group;
|
||||
|
||||
if (!(mindmap instanceof MindmapElementModel)) return;
|
||||
|
||||
const parent =
|
||||
target === 'sibling'
|
||||
? (mindmap.getParentNode(this.current.id) ?? this.current)
|
||||
: this.current;
|
||||
|
||||
const parentNode = mindmap.getNode(parent.id);
|
||||
|
||||
if (!parentNode) return;
|
||||
|
||||
const newNode = mindmap.addNode(
|
||||
parentNode.id,
|
||||
target === 'sibling' ? this.current.id : undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
if (parentNode.detail.collapsed) {
|
||||
mindmap.toggleCollapse(parentNode);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
mountShapeTextEditor(
|
||||
this.edgeless.service.getElementById(newNode) as ShapeElementModel,
|
||||
this.edgeless
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private _computeLine(
|
||||
type: Direction,
|
||||
curShape: ShapeElementModel,
|
||||
nextBound: Bound
|
||||
) {
|
||||
const startBound = this.current.elementBound;
|
||||
const { startPosition, endPosition } = getPosition(type);
|
||||
const nextShape = {
|
||||
xywh: nextBound.serialize(),
|
||||
rotate: curShape.rotate,
|
||||
shapeType: curShape.shapeType,
|
||||
};
|
||||
const startPoint = curShape.getRelativePointLocation(startPosition);
|
||||
const endPoint = curShape.getRelativePointLocation.call(
|
||||
nextShape,
|
||||
endPosition
|
||||
);
|
||||
|
||||
return this._pathGenerator.generateOrthogonalConnectorPath({
|
||||
startBound,
|
||||
endBound: nextBound,
|
||||
startPoint,
|
||||
endPoint,
|
||||
});
|
||||
}
|
||||
|
||||
private _computeNextBound(type: Direction) {
|
||||
if (isShape(this.current)) {
|
||||
const connectedShapes = this._getConnectedElements(this.current).filter(
|
||||
e => e instanceof ShapeElementModel
|
||||
) as ShapeElementModel[];
|
||||
return nextBound(type, this.current, connectedShapes);
|
||||
} else {
|
||||
const bound = this.current.elementBound;
|
||||
switch (type) {
|
||||
case Direction.Right: {
|
||||
bound.x += bound.w + MAIN_GAP;
|
||||
break;
|
||||
}
|
||||
case Direction.Bottom: {
|
||||
bound.y += bound.h + MAIN_GAP;
|
||||
break;
|
||||
}
|
||||
case Direction.Left: {
|
||||
bound.x -= bound.w + MAIN_GAP;
|
||||
break;
|
||||
}
|
||||
case Direction.Top: {
|
||||
bound.y -= bound.h + MAIN_GAP;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return bound;
|
||||
}
|
||||
}
|
||||
|
||||
private _createAutoCompletePanel(
|
||||
e: PointerEvent,
|
||||
connector: ConnectorElementModel
|
||||
) {
|
||||
if (!this.canShowAutoComplete) return;
|
||||
|
||||
const position = this.edgeless.service.viewport.toModelCoord(
|
||||
e.clientX,
|
||||
e.clientY
|
||||
);
|
||||
const autoCompletePanel = new EdgelessAutoCompletePanel(
|
||||
position,
|
||||
this.edgeless,
|
||||
this.current,
|
||||
connector
|
||||
);
|
||||
|
||||
this.edgeless.append(autoCompletePanel);
|
||||
}
|
||||
|
||||
private _generateElementOnClick(type: Direction) {
|
||||
const { doc, service } = this.edgeless;
|
||||
const bound = this._computeNextBound(type);
|
||||
const id = createEdgelessElement(this.edgeless, this.current, bound);
|
||||
if (isShape(this.current)) {
|
||||
const { startPosition, endPosition } = getPosition(type);
|
||||
this._addConnector(
|
||||
{
|
||||
id: this.current.id,
|
||||
position: startPosition,
|
||||
},
|
||||
{
|
||||
id,
|
||||
position: endPosition,
|
||||
}
|
||||
);
|
||||
|
||||
mountShapeTextEditor(
|
||||
service.getElementById(id) as ShapeElementModel,
|
||||
this.edgeless
|
||||
);
|
||||
} else {
|
||||
const model = doc.getBlockById(id);
|
||||
assertExists(model);
|
||||
const [x, y] = service.viewport.toViewCoord(
|
||||
bound.center[0],
|
||||
bound.y + DEFAULT_NOTE_HEIGHT / 2
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
handleNativeRangeAtPoint(x, y);
|
||||
});
|
||||
}
|
||||
|
||||
this.edgeless.service.selection.set({
|
||||
elements: [id],
|
||||
editing: true,
|
||||
});
|
||||
this.removeOverlay();
|
||||
}
|
||||
|
||||
private _getConnectedElements(element: ShapeElementModel) {
|
||||
const service = this.edgeless.service;
|
||||
|
||||
return service.getConnectors(element.id).reduce((prev, current) => {
|
||||
if (current.target.id === element.id && current.source.id) {
|
||||
prev.push(
|
||||
service.getElementById(current.source.id) as ShapeElementModel
|
||||
);
|
||||
}
|
||||
if (current.source.id === element.id && current.target.id) {
|
||||
prev.push(
|
||||
service.getElementById(current.target.id) as ShapeElementModel
|
||||
);
|
||||
}
|
||||
|
||||
return prev;
|
||||
}, [] as ShapeElementModel[]);
|
||||
}
|
||||
|
||||
private _getMindmapButtons() {
|
||||
const mindmap = this.current.group as MindmapElementModel;
|
||||
const mindmapDirection =
|
||||
this.current instanceof ShapeElementModel &&
|
||||
mindmap instanceof MindmapElementModel
|
||||
? mindmap.getLayoutDir(this.current.id)
|
||||
: null;
|
||||
const isRoot = mindmap?.tree.id === this.current.id;
|
||||
const mindmapNode = mindmap.getNode(this.current.id);
|
||||
|
||||
let buttons: [
|
||||
Direction,
|
||||
'child' | 'sibling',
|
||||
LayoutType.LEFT | LayoutType.RIGHT,
|
||||
][] = [];
|
||||
|
||||
switch (mindmapDirection) {
|
||||
case LayoutType.LEFT:
|
||||
buttons = [[Direction.Left, 'child', LayoutType.LEFT]];
|
||||
|
||||
if (!isRoot) {
|
||||
buttons.push([Direction.Bottom, 'sibling', mindmapDirection]);
|
||||
}
|
||||
break;
|
||||
case LayoutType.RIGHT:
|
||||
buttons = [[Direction.Right, 'child', LayoutType.RIGHT]];
|
||||
|
||||
if (!isRoot) {
|
||||
buttons.push([Direction.Bottom, 'sibling', mindmapDirection]);
|
||||
}
|
||||
break;
|
||||
case LayoutType.BALANCE:
|
||||
buttons = [
|
||||
[Direction.Right, 'child', LayoutType.RIGHT],
|
||||
[Direction.Left, 'child', LayoutType.LEFT],
|
||||
];
|
||||
break;
|
||||
default:
|
||||
buttons = [];
|
||||
}
|
||||
|
||||
return buttons.length
|
||||
? {
|
||||
mindmapNode,
|
||||
buttons,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
private _initOverlay() {
|
||||
const { surface } = this.edgeless;
|
||||
this._autoCompleteOverlay = new AutoCompleteOverlay(
|
||||
this.std.get(GfxControllerIdentifier)
|
||||
);
|
||||
surface.renderer.addOverlay(this._autoCompleteOverlay);
|
||||
}
|
||||
|
||||
private _renderArrow() {
|
||||
const isShape = this.current instanceof ShapeElementModel;
|
||||
const { selectedRect } = this;
|
||||
const { zoom } = this.edgeless.service.viewport;
|
||||
const width = 72;
|
||||
const height = 44;
|
||||
|
||||
// Auto-complete arrows for shape and note are different
|
||||
// Shape: right, bottom, left, top
|
||||
// Note: right, left
|
||||
const arrowDirections = isShape
|
||||
? [Direction.Right, Direction.Bottom, Direction.Left, Direction.Top]
|
||||
: [Direction.Right, Direction.Left];
|
||||
const arrowMargin = isShape ? height / 2 : height * (2 / 3);
|
||||
const Arrows = arrowDirections.map(type => {
|
||||
let transform = '';
|
||||
|
||||
const icon = isShape ? AutoCompleteArrowIcon : NoteAutoCompleteIcon;
|
||||
|
||||
switch (type) {
|
||||
case Direction.Top:
|
||||
transform += `translate(${
|
||||
selectedRect.width / 2
|
||||
}px, ${-arrowMargin}px)`;
|
||||
break;
|
||||
case Direction.Right:
|
||||
transform += `translate(${selectedRect.width + arrowMargin}px, ${
|
||||
selectedRect.height / 2
|
||||
}px)`;
|
||||
|
||||
isShape && (transform += `rotate(90deg)`);
|
||||
break;
|
||||
case Direction.Bottom:
|
||||
transform += `translate(${selectedRect.width / 2}px, ${
|
||||
selectedRect.height + arrowMargin
|
||||
}px)`;
|
||||
isShape && (transform += `rotate(180deg)`);
|
||||
break;
|
||||
case Direction.Left:
|
||||
transform += `translate(${-arrowMargin}px, ${
|
||||
selectedRect.height / 2
|
||||
}px)`;
|
||||
isShape && (transform += `rotate(-90deg)`);
|
||||
break;
|
||||
}
|
||||
transform += `translate(${-width / 2}px, ${-height / 2}px)`;
|
||||
const arrowWrapperClasses = classMap({
|
||||
'edgeless-auto-complete-arrow-wrapper': true,
|
||||
hidden: !isShape && type === Direction.Left && zoom >= 1.5,
|
||||
});
|
||||
|
||||
return html`<div
|
||||
class=${arrowWrapperClasses}
|
||||
style=${styleMap({
|
||||
transform,
|
||||
transformOrigin: 'left top',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
class="edgeless-auto-complete-arrow"
|
||||
@mouseenter=${() => {
|
||||
this._timer = setTimeout(() => {
|
||||
if (this.current instanceof ShapeElementModel) {
|
||||
const bound = this._computeNextBound(type);
|
||||
const path = this._computeLine(type, this.current, bound);
|
||||
this._showNextShape(
|
||||
this.current,
|
||||
bound,
|
||||
path,
|
||||
this.current.shapeType
|
||||
);
|
||||
}
|
||||
}, 300);
|
||||
}}
|
||||
@mouseleave=${() => {
|
||||
this.removeOverlay();
|
||||
}}
|
||||
@pointerdown=${(e: PointerEvent) => {
|
||||
this._onPointerDown(e, type);
|
||||
}}
|
||||
>
|
||||
${icon}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
return Arrows;
|
||||
}
|
||||
|
||||
private _renderMindMapButtons() {
|
||||
const mindmapButtons = this._getMindmapButtons();
|
||||
|
||||
if (!mindmapButtons) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedRect } = this;
|
||||
const { zoom } = this.edgeless.service.viewport;
|
||||
const size = 26;
|
||||
const buttonMargin =
|
||||
(mindmapButtons.mindmapNode?.children.length ?? 0) > 0
|
||||
? size / 2 + 32 * zoom
|
||||
: size / 2 + 6;
|
||||
const verticalMargin = size / 2 + 6;
|
||||
|
||||
return mindmapButtons.buttons.map(type => {
|
||||
let transform = '';
|
||||
|
||||
const [position, target, layout] = type;
|
||||
const isLeftLayout = layout === LayoutType.LEFT;
|
||||
const icon = target === 'child' ? MindMapChildIcon : MindMapSiblingIcon;
|
||||
|
||||
switch (position) {
|
||||
case Direction.Bottom:
|
||||
transform += `translate(${selectedRect.width / 2}px, ${
|
||||
selectedRect.height + verticalMargin
|
||||
}px)`;
|
||||
isLeftLayout && (transform += `scale(-1)`);
|
||||
break;
|
||||
case Direction.Right:
|
||||
transform += `translate(${selectedRect.width + buttonMargin}px, ${
|
||||
selectedRect.height / 2
|
||||
}px)`;
|
||||
break;
|
||||
case Direction.Left:
|
||||
transform += `translate(${-buttonMargin}px, ${
|
||||
selectedRect.height / 2
|
||||
}px)`;
|
||||
|
||||
transform += `scale(-1)`;
|
||||
break;
|
||||
}
|
||||
|
||||
transform += `translate(${-size / 2}px, ${-size / 2}px)`;
|
||||
|
||||
const arrowWrapperClasses = classMap({
|
||||
'edgeless-auto-complete-arrow-wrapper': true,
|
||||
hidden: position === Direction.Left && zoom >= 1.5,
|
||||
mindmap: true,
|
||||
});
|
||||
|
||||
return html`<div
|
||||
class=${arrowWrapperClasses}
|
||||
style=${styleMap({
|
||||
transform,
|
||||
transformOrigin: 'left top',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
class="edgeless-auto-complete-arrow"
|
||||
@pointerdown=${() => {
|
||||
this._addMindmapNode(target);
|
||||
}}
|
||||
>
|
||||
${icon}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
private _showNextShape(
|
||||
current: ShapeElementModel,
|
||||
bound: Bound,
|
||||
path: IVec[],
|
||||
targetType: ShapeType
|
||||
) {
|
||||
const { surface } = this.edgeless;
|
||||
|
||||
this._autoCompleteOverlay.stroke = surface.renderer.getColorValue(
|
||||
current.strokeColor,
|
||||
DEFAULT_SHAPE_STROKE_COLOR,
|
||||
true
|
||||
);
|
||||
this._autoCompleteOverlay.linePoints = path;
|
||||
this._autoCompleteOverlay.renderShape = ctx => {
|
||||
shapeMethods[targetType].draw(ctx, { ...bound, rotate: current.rotate });
|
||||
};
|
||||
surface.refresh();
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._pathGenerator = new ConnectorPathGenerator({
|
||||
getElementById: id => this.edgeless.service.getElementById(id),
|
||||
});
|
||||
this._initOverlay();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { _disposables, edgeless } = this;
|
||||
|
||||
_disposables.add(
|
||||
this.edgeless.service.selection.slots.updated.on(() => {
|
||||
this._autoCompleteOverlay.linePoints = [];
|
||||
this._autoCompleteOverlay.renderShape = null;
|
||||
})
|
||||
);
|
||||
|
||||
_disposables.add(() => this.removeOverlay());
|
||||
|
||||
_disposables.add(
|
||||
edgeless.host.event.add('pointerMove', ctx => {
|
||||
const evt = ctx.get('pointerState');
|
||||
const [x, y] = edgeless.gfx.viewport.toModelCoord(evt.x, evt.y);
|
||||
const elm = edgeless.gfx.getElementByPoint(x, y);
|
||||
|
||||
if (!elm) {
|
||||
this._isHover = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._isHover = elm === this.current ? true : false;
|
||||
})
|
||||
);
|
||||
|
||||
this.edgeless.handleEvent('dragStart', () => {
|
||||
this._isMoving = true;
|
||||
});
|
||||
this.edgeless.handleEvent('dragEnd', () => {
|
||||
this._isMoving = false;
|
||||
});
|
||||
}
|
||||
|
||||
removeOverlay() {
|
||||
this._timer && clearTimeout(this._timer);
|
||||
this.edgeless.surface.renderer.removeOverlay(this._autoCompleteOverlay);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const isShape = this.current instanceof ShapeElementModel;
|
||||
const isMindMap = this.current.group instanceof MindmapElementModel;
|
||||
|
||||
if (this._isMoving || (this._isHover && !isShape)) {
|
||||
this.removeOverlay();
|
||||
return nothing;
|
||||
}
|
||||
const { selectedRect } = this;
|
||||
|
||||
return html`<div
|
||||
class="edgeless-auto-complete-container"
|
||||
style=${styleMap({
|
||||
top: selectedRect.top + 'px',
|
||||
left: selectedRect.left + 'px',
|
||||
width: selectedRect.width + 'px',
|
||||
height: selectedRect.height + 'px',
|
||||
transform: `rotate(${selectedRect.rotate}deg)`,
|
||||
})}
|
||||
>
|
||||
${isMindMap ? this._renderMindMapButtons() : this._renderArrow()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _isHover = true;
|
||||
|
||||
@state()
|
||||
private accessor _isMoving = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor current!: ShapeElementModel | NoteBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor selectedRect!: SelectedRect;
|
||||
|
||||
@consume({
|
||||
context: stdContext,
|
||||
})
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-auto-complete': EdgelessAutoComplete;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
CommonUtils,
|
||||
type Options,
|
||||
Overlay,
|
||||
type RoughCanvas,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type Connection,
|
||||
getShapeRadius,
|
||||
getShapeType,
|
||||
GroupElementModel,
|
||||
type NoteBlockModel,
|
||||
ShapeElementModel,
|
||||
type ShapeName,
|
||||
type ShapeStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { GfxController, GfxModel } from '@blocksuite/block-std/gfx';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { XYWH } from '@blocksuite/global/utils';
|
||||
import { assertType, Bound } from '@blocksuite/global/utils';
|
||||
import { DocCollection } from '@blocksuite/store';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
import { type Shape, ShapeFactory } from '../../utils/tool-overlay.js';
|
||||
|
||||
export enum Direction {
|
||||
Right,
|
||||
Bottom,
|
||||
Left,
|
||||
Top,
|
||||
}
|
||||
|
||||
export const PANEL_WIDTH = 136;
|
||||
export const PANEL_HEIGHT = 108;
|
||||
|
||||
export const MAIN_GAP = 100;
|
||||
export const SECOND_GAP = 20;
|
||||
export const DEFAULT_NOTE_OVERLAY_HEIGHT = 110;
|
||||
export const DEFAULT_TEXT_WIDTH = 116;
|
||||
export const DEFAULT_TEXT_HEIGHT = 24;
|
||||
|
||||
export type TARGET_SHAPE_TYPE = ShapeName;
|
||||
export type AUTO_COMPLETE_TARGET_TYPE =
|
||||
| TARGET_SHAPE_TYPE
|
||||
| 'text'
|
||||
| 'note'
|
||||
| 'frame';
|
||||
|
||||
class AutoCompleteTargetOverlay extends Overlay {
|
||||
xywh: XYWH;
|
||||
|
||||
constructor(gfx: GfxController, xywh: XYWH) {
|
||||
super(gfx);
|
||||
this.xywh = xywh;
|
||||
}
|
||||
|
||||
override render(_ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {}
|
||||
}
|
||||
|
||||
export class AutoCompleteTextOverlay extends AutoCompleteTargetOverlay {
|
||||
constructor(gfx: GfxController, xywh: XYWH) {
|
||||
super(gfx, xywh);
|
||||
}
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
|
||||
const [x, y, w, h] = this.xywh;
|
||||
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.strokeStyle = '#1e96eb';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
|
||||
// fill text placeholder
|
||||
ctx.font = '15px sans-serif';
|
||||
ctx.fillStyle = '#C0BFC1';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText("Type '/' to insert", x + w / 2, y + h / 2);
|
||||
}
|
||||
}
|
||||
|
||||
export class AutoCompleteNoteOverlay extends AutoCompleteTargetOverlay {
|
||||
private _background: string;
|
||||
|
||||
constructor(gfx: GfxController, xywh: XYWH, background: string) {
|
||||
super(gfx, xywh);
|
||||
this._background = background;
|
||||
}
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
|
||||
const [x, y, w, h] = this.xywh;
|
||||
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.fillStyle = this._background;
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.10)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, w, h, 8);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// fill text placeholder
|
||||
ctx.font = '15px sans-serif';
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText("Type '/' for command", x + 24, y + h / 2);
|
||||
}
|
||||
}
|
||||
|
||||
export class AutoCompleteFrameOverlay extends AutoCompleteTargetOverlay {
|
||||
private _strokeColor;
|
||||
|
||||
constructor(gfx: GfxController, xywh: XYWH, strokeColor: string) {
|
||||
super(gfx, xywh);
|
||||
this._strokeColor = strokeColor;
|
||||
}
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
|
||||
const [x, y, w, h] = this.xywh;
|
||||
// frame title background
|
||||
const titleWidth = 72;
|
||||
const titleHeight = 30;
|
||||
const titleY = y - titleHeight - 10;
|
||||
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, titleY, titleWidth, titleHeight, 4);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// fill title text
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('Frame', x + titleWidth / 2, titleY + titleHeight / 2);
|
||||
|
||||
// frame stroke
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.strokeStyle = this._strokeColor;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, w, h, 8);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
export class AutoCompleteShapeOverlay extends Overlay {
|
||||
private _shape: Shape;
|
||||
|
||||
constructor(
|
||||
gfx: GfxController,
|
||||
xywh: XYWH,
|
||||
type: TARGET_SHAPE_TYPE,
|
||||
options: Options,
|
||||
shapeStyle: ShapeStyle
|
||||
) {
|
||||
super(gfx);
|
||||
this._shape = ShapeFactory.createShape(xywh, type, options, shapeStyle);
|
||||
}
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D, rc: RoughCanvas) {
|
||||
ctx.globalAlpha = 0.4;
|
||||
this._shape.draw(ctx, rc);
|
||||
}
|
||||
}
|
||||
|
||||
export function nextBound(
|
||||
type: Direction,
|
||||
curShape: ShapeElementModel,
|
||||
elements: ShapeElementModel[]
|
||||
) {
|
||||
const bound = Bound.deserialize(curShape.xywh);
|
||||
const { x, y, w, h } = bound;
|
||||
let nextBound: Bound;
|
||||
let angle = 0;
|
||||
switch (type) {
|
||||
case Direction.Right:
|
||||
angle = 0;
|
||||
break;
|
||||
case Direction.Bottom:
|
||||
angle = 90;
|
||||
break;
|
||||
case Direction.Left:
|
||||
angle = 180;
|
||||
break;
|
||||
case Direction.Top:
|
||||
angle = 270;
|
||||
break;
|
||||
}
|
||||
angle = CommonUtils.normalizeDegAngle(angle + curShape.rotate);
|
||||
|
||||
if (angle >= 45 && angle <= 135) {
|
||||
nextBound = new Bound(x, y + h + MAIN_GAP, w, h);
|
||||
} else if (angle >= 135 && angle <= 225) {
|
||||
nextBound = new Bound(x - w - MAIN_GAP, y, w, h);
|
||||
} else if (angle >= 225 && angle <= 315) {
|
||||
nextBound = new Bound(x, y - h - MAIN_GAP, w, h);
|
||||
} else {
|
||||
nextBound = new Bound(x + w + MAIN_GAP, y, w, h);
|
||||
}
|
||||
|
||||
function isValidBound(bound: Bound) {
|
||||
return !elements.some(a => bound.isOverlapWithBound(a.elementBound));
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
function findValidBound() {
|
||||
count++;
|
||||
const number = Math.ceil(count / 2);
|
||||
const next = nextBound.clone();
|
||||
switch (type) {
|
||||
case Direction.Right:
|
||||
case Direction.Left:
|
||||
next.y =
|
||||
count % 2 === 1
|
||||
? nextBound.y - (h + SECOND_GAP) * number
|
||||
: nextBound.y + (h + SECOND_GAP) * number;
|
||||
break;
|
||||
case Direction.Bottom:
|
||||
case Direction.Top:
|
||||
next.x =
|
||||
count % 2 === 1
|
||||
? nextBound.x - (w + SECOND_GAP) * number
|
||||
: nextBound.x + (w + SECOND_GAP) * number;
|
||||
break;
|
||||
}
|
||||
if (isValidBound(next)) return next;
|
||||
return findValidBound();
|
||||
}
|
||||
|
||||
return isValidBound(nextBound) ? nextBound : findValidBound();
|
||||
}
|
||||
|
||||
export function getPosition(type: Direction) {
|
||||
let startPosition: Connection['position'];
|
||||
let endPosition: Connection['position'];
|
||||
|
||||
switch (type) {
|
||||
case Direction.Right:
|
||||
startPosition = [1, 0.5];
|
||||
endPosition = [0, 0.5];
|
||||
break;
|
||||
case Direction.Bottom:
|
||||
startPosition = [0.5, 1];
|
||||
endPosition = [0.5, 0];
|
||||
break;
|
||||
case Direction.Left:
|
||||
startPosition = [0, 0.5];
|
||||
endPosition = [1, 0.5];
|
||||
break;
|
||||
case Direction.Top:
|
||||
startPosition = [0.5, 0];
|
||||
endPosition = [0.5, 1];
|
||||
break;
|
||||
}
|
||||
return { startPosition, endPosition };
|
||||
}
|
||||
|
||||
export function isShape(element: unknown): element is ShapeElementModel {
|
||||
return element instanceof ShapeElementModel;
|
||||
}
|
||||
|
||||
export function capitalizeFirstLetter(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
export function createEdgelessElement(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
current: ShapeElementModel | NoteBlockModel,
|
||||
bound: Bound
|
||||
) {
|
||||
let id;
|
||||
const { service } = edgeless;
|
||||
|
||||
let element: GfxModel | null = null;
|
||||
|
||||
if (isShape(current)) {
|
||||
id = service.addElement(current.type, {
|
||||
...current.serialize(),
|
||||
text: new DocCollection.Y.Text(),
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
element = service.getElementById(id);
|
||||
} else {
|
||||
const { doc } = edgeless;
|
||||
id = doc.addBlock(
|
||||
'affine:note',
|
||||
{
|
||||
background: current.background,
|
||||
displayMode: current.displayMode,
|
||||
edgeless: current.edgeless,
|
||||
xywh: bound.serialize(),
|
||||
},
|
||||
edgeless.model.id
|
||||
);
|
||||
const note = doc.getBlock(id)?.model;
|
||||
if (!note) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.GfxBlockElementError,
|
||||
'Note block is not found after creation'
|
||||
);
|
||||
}
|
||||
assertType<NoteBlockModel>(note);
|
||||
doc.updateBlock(note, () => {
|
||||
note.edgeless.collapse = true;
|
||||
});
|
||||
doc.addBlock('affine:paragraph', {}, note.id);
|
||||
|
||||
element = note;
|
||||
}
|
||||
|
||||
if (!element) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.GfxBlockElementError,
|
||||
'Element is not found after creation'
|
||||
);
|
||||
}
|
||||
|
||||
const group = current.group;
|
||||
if (group instanceof GroupElementModel) {
|
||||
group.addChild(element);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function createShapeElement(
|
||||
edgeless: EdgelessRootBlockComponent,
|
||||
current: ShapeElementModel | NoteBlockModel,
|
||||
targetType: TARGET_SHAPE_TYPE
|
||||
) {
|
||||
const service = edgeless.service;
|
||||
const id = service.addElement('shape', {
|
||||
shapeType: getShapeType(targetType),
|
||||
radius: getShapeRadius(targetType),
|
||||
text: new DocCollection.Y.Text(),
|
||||
});
|
||||
const element = service.getElementById(id);
|
||||
const group = current.group;
|
||||
if (group instanceof GroupElementModel && element) {
|
||||
group.addChild(element);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-tool-icon-button': EdgelessToolIconButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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()} `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-toolbar-button': EdgelessToolbarButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { EditorMenuButton } from '@blocksuite/affine-components/toolbar';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { html, LitElement } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { ColorEvent } from '../panel/color-panel.js';
|
||||
import type {
|
||||
ModeType,
|
||||
PickColorDetail,
|
||||
PickColorEvent,
|
||||
PickColorType,
|
||||
} from './types.js';
|
||||
import { keepColor, preprocessColor } from './utils.js';
|
||||
|
||||
type Type = 'normal' | 'custom';
|
||||
|
||||
export class EdgelessColorPickerButton extends WithDisposable(LitElement) {
|
||||
#select = (e: ColorEvent) => {
|
||||
this.#pick({ palette: e.detail });
|
||||
};
|
||||
|
||||
switchToCustomTab = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (this.colorType === 'palette') {
|
||||
this.colorType = 'normal';
|
||||
}
|
||||
this.tabType = 'custom';
|
||||
// refresh menu's position
|
||||
this.menuButton.show(true);
|
||||
};
|
||||
|
||||
get colorWithoutAlpha() {
|
||||
return this.isCSSVariable ? this.color : keepColor(this.color);
|
||||
}
|
||||
|
||||
get customButtonStyle() {
|
||||
let b = 'transparent';
|
||||
let c = 'transparent';
|
||||
if (!this.isCSSVariable) {
|
||||
b = 'var(--affine-background-overlay-panel-color)';
|
||||
c = keepColor(this.color);
|
||||
}
|
||||
return { '--b': b, '--c': c };
|
||||
}
|
||||
|
||||
get isCSSVariable() {
|
||||
return this.color.startsWith('--');
|
||||
}
|
||||
|
||||
get tabContentPadding() {
|
||||
return `${this.tabType === 'custom' ? 0 : 8}px`;
|
||||
}
|
||||
|
||||
#pick(detail: PickColorDetail) {
|
||||
this.pick?.({ type: 'start' });
|
||||
this.pick?.({ type: 'pick', detail });
|
||||
this.pick?.({ type: 'end' });
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this.disposables.addFromEvent(this.menuButton, 'toggle', (e: Event) => {
|
||||
const opened = (e as CustomEvent<boolean>).detail;
|
||||
if (!opened && this.tabType !== 'normal') {
|
||||
this.tabType = 'normal';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${this.tabContentPadding}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label=${this.label}
|
||||
.tooltip=${this.tooltip || this.label}
|
||||
>
|
||||
${this.isText
|
||||
? html`
|
||||
<edgeless-text-color-icon
|
||||
.color=${this.colorWithoutAlpha}
|
||||
></edgeless-text-color-icon>
|
||||
`
|
||||
: html`
|
||||
<edgeless-color-button
|
||||
.color=${this.colorWithoutAlpha}
|
||||
.hollowCircle=${this.hollowCircle}
|
||||
></edgeless-color-button>
|
||||
`}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
${choose(this.tabType, [
|
||||
[
|
||||
'normal',
|
||||
() => html`
|
||||
<div data-orientation="vertical">
|
||||
<slot name="other"></slot>
|
||||
<slot name="separator"></slot>
|
||||
<edgeless-color-panel
|
||||
role="listbox"
|
||||
.value=${this.color}
|
||||
.options=${this.palettes}
|
||||
.hollowCircle=${this.hollowCircle}
|
||||
.openColorPicker=${this.switchToCustomTab}
|
||||
.hasTransparent=${false}
|
||||
@select=${this.#select}
|
||||
>
|
||||
<edgeless-color-custom-button
|
||||
slot="custom"
|
||||
style=${styleMap(this.customButtonStyle)}
|
||||
.active=${!this.isCSSVariable}
|
||||
@click=${this.switchToCustomTab}
|
||||
></edgeless-color-custom-button>
|
||||
</edgeless-color-panel>
|
||||
</div>
|
||||
`,
|
||||
],
|
||||
[
|
||||
'custom',
|
||||
() => html`
|
||||
<edgeless-color-picker
|
||||
class="custom"
|
||||
.pick=${this.pick}
|
||||
.colors=${{
|
||||
type:
|
||||
this.colorType === 'palette' ? 'normal' : this.colorType,
|
||||
modes: this.colors.map(
|
||||
preprocessColor(window.getComputedStyle(this))
|
||||
),
|
||||
}}
|
||||
></edgeless-color-picker>
|
||||
`,
|
||||
],
|
||||
])}
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@property()
|
||||
accessor color!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor colors: { type: ModeType; value: string }[] = [];
|
||||
|
||||
@property()
|
||||
accessor colorType: PickColorType = 'palette';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hollowCircle: boolean = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isText!: boolean;
|
||||
|
||||
@property()
|
||||
accessor label!: string;
|
||||
|
||||
@query('editor-menu-button')
|
||||
accessor menuButton!: EditorMenuButton;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor palettes: string[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor pick!: (event: PickColorEvent) => void;
|
||||
|
||||
@state()
|
||||
accessor tabType: Type = 'normal';
|
||||
|
||||
@property()
|
||||
accessor tooltip: string | undefined = undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-color-picker-button': EdgelessColorPickerButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
import { on, once, stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { batch, computed, signal } from '@preact/signals-core';
|
||||
import { html, LitElement } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { AREA_CIRCLE_R, MATCHERS, SLIDER_CIRCLE_R } from './consts.js';
|
||||
import { COLOR_PICKER_STYLE } from './styles.js';
|
||||
import type {
|
||||
Hsva,
|
||||
ModeRgba,
|
||||
ModeTab,
|
||||
ModeType,
|
||||
NavTab,
|
||||
NavType,
|
||||
PickColorEvent,
|
||||
Point,
|
||||
Rgb,
|
||||
} from './types.js';
|
||||
import {
|
||||
bound01,
|
||||
clamp,
|
||||
defaultHsva,
|
||||
eq,
|
||||
hsvaToHex8,
|
||||
hsvaToRgba,
|
||||
linearGradientAt,
|
||||
parseHexToHsva,
|
||||
renderCanvas,
|
||||
rgbaToHex8,
|
||||
rgbaToHsva,
|
||||
rgbToHex,
|
||||
rgbToHsv,
|
||||
} from './utils.js';
|
||||
|
||||
const TABS: NavTab<NavType>[] = [
|
||||
{ type: 'colors', name: 'Colors' },
|
||||
{ type: 'custom', name: 'Custom' },
|
||||
];
|
||||
|
||||
export class EdgelessColorPicker extends SignalWatcher(
|
||||
WithDisposable(LitElement)
|
||||
) {
|
||||
static override styles = COLOR_PICKER_STYLE;
|
||||
|
||||
#alphaRect = new DOMRect();
|
||||
|
||||
#editAlpha = (e: InputEvent) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const orignalValue = target.value;
|
||||
let value = orignalValue.trim().replace(/[^0-9]/, '');
|
||||
|
||||
const alpha = clamp(0, Number(value), 100);
|
||||
const a = bound01(alpha, 100);
|
||||
const hsva = this.hsva$.peek();
|
||||
|
||||
value = `${alpha}`;
|
||||
if (orignalValue !== value) {
|
||||
target.value = value;
|
||||
}
|
||||
|
||||
if (hsva.a === a) return;
|
||||
|
||||
const x = this.#alphaRect.width * a;
|
||||
this.alphaPosX$.value = x;
|
||||
|
||||
this.#pick();
|
||||
};
|
||||
|
||||
#editHex = (e: KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
const orignalValue = target.value;
|
||||
let value = orignalValue.trim().replace(MATCHERS.other, '');
|
||||
let matched;
|
||||
if (
|
||||
(matched = value.match(MATCHERS.hex3)) ||
|
||||
(matched = value.match(MATCHERS.hex6))
|
||||
) {
|
||||
const oldHsva = this.hsva$.peek();
|
||||
const hsv = parseHexToHsva(matched[1]);
|
||||
const newHsva = { ...oldHsva, ...hsv };
|
||||
|
||||
value = rgbToHex(hsvaToRgba(newHsva));
|
||||
if (orignalValue !== value) {
|
||||
target.value = value;
|
||||
}
|
||||
|
||||
if (eq(newHsva, oldHsva)) return;
|
||||
|
||||
this.#setControlsPos(newHsva);
|
||||
|
||||
this.#pick();
|
||||
} else {
|
||||
target.value = this.hex6WithoutHash$.peek();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#hueRect = new DOMRect();
|
||||
|
||||
#paletteRect = new DOMRect();
|
||||
|
||||
#pick() {
|
||||
const hsva = this.hsva$.peek();
|
||||
const type = this.modeType$.peek();
|
||||
const detail = { [type]: hsvaToHex8(hsva) };
|
||||
|
||||
if (type !== 'normal') {
|
||||
const another = type === 'light' ? 'dark' : 'light';
|
||||
const { hsva } = this[`${another}$`].peek();
|
||||
detail[another] = hsvaToHex8(hsva);
|
||||
}
|
||||
|
||||
this.pick?.({ type: 'pick', detail });
|
||||
}
|
||||
|
||||
#pickEnd() {
|
||||
this.pick?.({ type: 'end' });
|
||||
}
|
||||
|
||||
#pickStart() {
|
||||
this.pick?.({ type: 'start' });
|
||||
}
|
||||
|
||||
#setAlphaPos(clientX: number) {
|
||||
const { left, width } = this.#alphaRect;
|
||||
const x = clamp(0, clientX - left, width);
|
||||
|
||||
this.alphaPosX$.value = x;
|
||||
}
|
||||
|
||||
#setAlphaPosWithWheel(y: number) {
|
||||
const { width } = this.#alphaRect;
|
||||
const px = this.alphaPosX$.peek();
|
||||
const ax = clamp(0, px + (y * width) / 100, width);
|
||||
|
||||
this.alphaPosX$.value = ax;
|
||||
}
|
||||
|
||||
#setControlsPos({ h, s, v, a }: Hsva) {
|
||||
const hx = this.#hueRect.width * h;
|
||||
const px = this.#paletteRect.width * s;
|
||||
const py = this.#paletteRect.height * (1 - v);
|
||||
const ax = this.#alphaRect.width * a;
|
||||
|
||||
batch(() => {
|
||||
this.huePosX$.value = hx;
|
||||
this.alphaPosX$.value = ax;
|
||||
this.palettePos$.value = { x: px, y: py };
|
||||
});
|
||||
}
|
||||
|
||||
#setHuePos(clientX: number) {
|
||||
const { left, width } = this.#hueRect;
|
||||
const x = clamp(0, clientX - left, width);
|
||||
|
||||
this.huePosX$.value = x;
|
||||
}
|
||||
|
||||
#setHuePosWithWheel(y: number) {
|
||||
const { width } = this.#hueRect;
|
||||
const px = this.huePosX$.peek();
|
||||
const ax = clamp(0, px + (y * width) / 100, width);
|
||||
|
||||
this.huePosX$.value = ax;
|
||||
}
|
||||
|
||||
#setPalettePos(clientX: number, clientY: number) {
|
||||
const { left, top, width, height } = this.#paletteRect;
|
||||
const x = clamp(0, clientX - left, width);
|
||||
const y = clamp(0, clientY - top, height);
|
||||
|
||||
this.palettePos$.value = { x, y };
|
||||
}
|
||||
|
||||
#setPalettePosWithWheel(x: number, y: number) {
|
||||
const { width, height } = this.#paletteRect;
|
||||
const pos = this.palettePos$.peek();
|
||||
const px = clamp(0, pos.x + (x * width) / 100, width);
|
||||
const py = clamp(0, pos.y + (y * height) / 100, height);
|
||||
|
||||
this.palettePos$.value = { x: px, y: py };
|
||||
}
|
||||
|
||||
#setRect({ left, top, width, height }: DOMRect, offset: number) {
|
||||
return new DOMRect(
|
||||
left + offset,
|
||||
top + offset,
|
||||
Math.round(width - offset * 2),
|
||||
Math.round(height - offset * 2)
|
||||
);
|
||||
}
|
||||
|
||||
#setRects() {
|
||||
this.#paletteRect = this.#setRect(
|
||||
this.paletteControl.getBoundingClientRect(),
|
||||
AREA_CIRCLE_R
|
||||
);
|
||||
|
||||
this.#hueRect = this.#setRect(
|
||||
this.hueControl.getBoundingClientRect(),
|
||||
SLIDER_CIRCLE_R
|
||||
);
|
||||
|
||||
this.#alphaRect = this.#setRect(
|
||||
this.alphaControl.getBoundingClientRect(),
|
||||
SLIDER_CIRCLE_R
|
||||
);
|
||||
}
|
||||
|
||||
#switchModeTab(type: ModeType) {
|
||||
this.modeType$.value = type;
|
||||
this.#setControlsPos(this.mode$.peek().hsva);
|
||||
}
|
||||
|
||||
#switchNavTab(type: NavType) {
|
||||
this.navType$.value = type;
|
||||
|
||||
if (type === 'colors') {
|
||||
const mode = this.mode$.peek();
|
||||
this.modes$.value[0].hsva = { ...mode.hsva };
|
||||
this.modeType$.value = 'normal';
|
||||
} else {
|
||||
const [normal, light, dark] = this.modes$.value;
|
||||
light.hsva = { ...normal.hsva };
|
||||
dark.hsva = { ...normal.hsva };
|
||||
this.modeType$.value = 'light';
|
||||
}
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
let clicked = false;
|
||||
let dragged = false;
|
||||
let outed = false;
|
||||
let picked = false;
|
||||
|
||||
let pointerenter: (() => void) | null;
|
||||
let pointermove: (() => void) | null;
|
||||
let pointerout: (() => void) | null;
|
||||
let timerId = 0;
|
||||
|
||||
this.disposables.addFromEvent(this, 'wheel', (e: WheelEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const target = e.composedPath()[0] as HTMLElement;
|
||||
const isInHue = target === this.hueControl;
|
||||
const isInAlpha = !isInHue && target === this.alphaControl;
|
||||
const isInPalette = !isInAlpha && target === this.paletteControl;
|
||||
picked = isInHue || isInAlpha || isInPalette;
|
||||
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
|
||||
// update target rect
|
||||
if (picked) {
|
||||
if (!timerId) {
|
||||
this.#pickStart();
|
||||
}
|
||||
timerId = window.setTimeout(() => {
|
||||
this.#pickEnd();
|
||||
timerId = 0;
|
||||
}, 110);
|
||||
}
|
||||
|
||||
const update = (x: number, y: number) => {
|
||||
if (!picked) return;
|
||||
|
||||
const absX = Math.abs(x);
|
||||
const absY = Math.abs(y);
|
||||
|
||||
x = Math.sign(x);
|
||||
y = Math.sign(y);
|
||||
|
||||
if (Math.hypot(x, y) === 0) return;
|
||||
|
||||
x *= Math.max(1, Math.log10(absX)) * -1;
|
||||
y *= Math.max(1, Math.log10(absY)) * -1;
|
||||
|
||||
if (isInHue) {
|
||||
this.#setHuePosWithWheel(x | y);
|
||||
}
|
||||
|
||||
if (isInAlpha) {
|
||||
this.#setAlphaPosWithWheel(x | y);
|
||||
}
|
||||
|
||||
if (isInPalette) {
|
||||
this.#setPalettePosWithWheel(x, y);
|
||||
}
|
||||
|
||||
this.#pick();
|
||||
};
|
||||
|
||||
update(e.deltaX, e.deltaY);
|
||||
});
|
||||
|
||||
this.disposables.addFromEvent(this, 'pointerdown', (e: PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
timerId = 0;
|
||||
}
|
||||
|
||||
// checks pointer enter/out
|
||||
pointerenter = on(this, 'pointerenter', () => (outed = false));
|
||||
pointerout = on(this, 'pointerout', () => (outed = true));
|
||||
// cleanups
|
||||
once(document, 'pointerup', () => {
|
||||
pointerenter?.();
|
||||
pointermove?.();
|
||||
pointerout?.();
|
||||
|
||||
if (picked) {
|
||||
this.#pickEnd();
|
||||
}
|
||||
|
||||
// When dragging the points, the color picker panel should not be triggered to close.
|
||||
if (dragged && outed) {
|
||||
once(document, 'click', stopPropagation, true);
|
||||
}
|
||||
|
||||
pointerenter = pointermove = pointerout = null;
|
||||
clicked = dragged = outed = picked = false;
|
||||
});
|
||||
|
||||
clicked = true;
|
||||
|
||||
const target = e.composedPath()[0] as HTMLElement;
|
||||
const isInHue = target === this.hueControl;
|
||||
const isInAlpha = !isInHue && target === this.alphaControl;
|
||||
const isInPalette = !isInAlpha && target === this.paletteControl;
|
||||
picked = isInHue || isInAlpha || isInPalette;
|
||||
|
||||
// update target rect
|
||||
if (picked) {
|
||||
this.#pickStart();
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
if (isInHue) {
|
||||
this.#hueRect = this.#setRect(rect, SLIDER_CIRCLE_R);
|
||||
} else if (isInAlpha) {
|
||||
this.#alphaRect = this.#setRect(rect, SLIDER_CIRCLE_R);
|
||||
} else if (isInPalette) {
|
||||
this.#paletteRect = this.#setRect(rect, AREA_CIRCLE_R);
|
||||
}
|
||||
}
|
||||
|
||||
const update = (x: number, y: number) => {
|
||||
if (!picked) return;
|
||||
|
||||
if (isInHue) {
|
||||
this.#setHuePos(x);
|
||||
}
|
||||
|
||||
if (isInAlpha) {
|
||||
this.#setAlphaPos(x);
|
||||
}
|
||||
|
||||
if (isInPalette) {
|
||||
this.#setPalettePos(x, y);
|
||||
}
|
||||
|
||||
this.#pick();
|
||||
};
|
||||
|
||||
update(e.x, e.y);
|
||||
|
||||
pointermove = on(document, 'pointermove', (e: PointerEvent) => {
|
||||
if (!clicked) return;
|
||||
if (!dragged) dragged = true;
|
||||
|
||||
update(e.x, e.y);
|
||||
});
|
||||
});
|
||||
this.disposables.addFromEvent(this, 'click', stopPropagation);
|
||||
|
||||
const batches: (() => void)[] = [];
|
||||
const { type, modes } = this.colors;
|
||||
|
||||
// Updates UI states
|
||||
if (['dark', 'light'].includes(type)) {
|
||||
batches.push(() => {
|
||||
this.modeType$.value = type;
|
||||
this.navType$.value = 'custom';
|
||||
});
|
||||
}
|
||||
|
||||
// Updates modes
|
||||
if (modes?.length) {
|
||||
batches.push(() => {
|
||||
// eslint-disable-next-line sonarjs/no-ignored-return
|
||||
this.modes$.value.reduce((fallback, curr, n) => {
|
||||
const m = modes[n];
|
||||
curr.hsva = m ? rgbaToHsva(m.rgba) : fallback;
|
||||
return { ...curr.hsva };
|
||||
}, defaultHsva());
|
||||
});
|
||||
}
|
||||
|
||||
// Updates controls' positions
|
||||
batches.push(() => {
|
||||
const mode = this.mode$.peek();
|
||||
this.#setControlsPos(mode.hsva);
|
||||
});
|
||||
|
||||
// Updates controls' rects
|
||||
this.#setRects();
|
||||
|
||||
batch(() => batches.forEach(fn => fn()));
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
this.disposables.add(
|
||||
this.hsva$.subscribe((hsva: Hsva) => {
|
||||
const type = this.modeType$.peek();
|
||||
const mode = this.modes$.value.find(mode => mode.type === type);
|
||||
|
||||
if (mode) {
|
||||
mode.hsva = { ...hsva };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.huePosX$.subscribe((x: number) => {
|
||||
const { width } = this.#hueRect;
|
||||
const rgb = linearGradientAt(bound01(x, width));
|
||||
|
||||
// Updates palette canvas
|
||||
renderCanvas(this.canvas, rgb);
|
||||
|
||||
this.hue$.value = rgb;
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.hue$.subscribe((rgb: Rgb) => {
|
||||
const hsva = this.hsva$.peek();
|
||||
const h = rgbToHsv(rgb).h;
|
||||
|
||||
this.hsva$.value = { ...hsva, h };
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.alphaPosX$.subscribe((x: number) => {
|
||||
const hsva = this.hsva$.peek();
|
||||
const { width } = this.#alphaRect;
|
||||
const a = bound01(x, width);
|
||||
|
||||
this.hsva$.value = { ...hsva, a };
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.palettePos$.subscribe(({ x, y }: Point) => {
|
||||
const hsva = this.hsva$.peek();
|
||||
const { width, height } = this.#paletteRect;
|
||||
const s = bound01(x, width);
|
||||
const v = bound01(height - y, height);
|
||||
|
||||
this.hsva$.value = { ...hsva, s, v };
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<header>
|
||||
<nav>
|
||||
${repeat(
|
||||
TABS,
|
||||
tab => tab.type,
|
||||
({ type, name }) => html`
|
||||
<button
|
||||
?active=${type === this.navType$.value}
|
||||
@click=${() => this.#switchNavTab(type)}
|
||||
>
|
||||
${name}
|
||||
</button>
|
||||
`
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="modes" ?active=${this.navType$.value === 'custom'}>
|
||||
${repeat(
|
||||
[this.light$.value, this.dark$.value],
|
||||
mode => mode.type,
|
||||
({ type, name, hsva }) => html`
|
||||
<div
|
||||
class="${classMap({ mode: true, [type]: true })}"
|
||||
style=${styleMap({ '--c': hsvaToHex8(hsva) })}
|
||||
>
|
||||
<button
|
||||
?active=${this.modeType$.value === type}
|
||||
@click=${() => this.#switchModeTab(type)}
|
||||
>
|
||||
<div class="color"></div>
|
||||
<div>${name}</div>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div
|
||||
class="color-palette-wrapper"
|
||||
style=${styleMap(this.paletteStyle$.value)}
|
||||
>
|
||||
<canvas></canvas>
|
||||
<div class="color-circle"></div>
|
||||
<div class="color-palette"></div>
|
||||
</div>
|
||||
<div
|
||||
class="color-slider-wrapper hue"
|
||||
style=${styleMap(this.hueStyle$.value)}
|
||||
>
|
||||
<div class="color-circle"></div>
|
||||
<div class="color-slider"></div>
|
||||
</div>
|
||||
<div
|
||||
class="color-slider-wrapper alpha"
|
||||
style=${styleMap(this.alphaStyle$.value)}
|
||||
>
|
||||
<div class="color-circle"></div>
|
||||
<div class="color-slider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<label class="field color">
|
||||
<span>#</span>
|
||||
<input
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
minlength="1"
|
||||
maxlength="6"
|
||||
.value=${live(this.hex6WithoutHash$.value)}
|
||||
@keydown=${this.#editHex}
|
||||
@cut=${stopPropagation}
|
||||
@copy=${stopPropagation}
|
||||
@paste=${stopPropagation}
|
||||
/>
|
||||
</label>
|
||||
<label class="field alpha">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
.value=${live(this.alpha100$.value)}
|
||||
@input=${this.#editAlpha}
|
||||
@cut=${stopPropagation}
|
||||
@copy=${stopPropagation}
|
||||
@paste=${stopPropagation}
|
||||
/>
|
||||
<span>%</span>
|
||||
</label>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
|
||||
// 0-100
|
||||
accessor alpha100$ = computed(
|
||||
() => `${Math.round(this.hsva$.value.a * 100)}`
|
||||
);
|
||||
|
||||
@query('.color-slider-wrapper.alpha .color-slider')
|
||||
accessor alphaControl!: HTMLDivElement;
|
||||
|
||||
accessor alphaPosX$ = signal<number>(0);
|
||||
|
||||
accessor alphaStyle$ = computed(() => {
|
||||
const x = this.alphaPosX$.value;
|
||||
const rgba = this.rgba$.value;
|
||||
const hex = `#${rgbToHex(rgba)}`;
|
||||
return {
|
||||
'--o': rgba.a,
|
||||
'--s': `${hex}00`,
|
||||
'--c': `${hex}ff`,
|
||||
'--x': `${x}px`,
|
||||
'--r': `${SLIDER_CIRCLE_R}px`,
|
||||
};
|
||||
});
|
||||
|
||||
@query('canvas')
|
||||
accessor canvas!: HTMLCanvasElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor colors: { type: ModeType; modes?: ModeRgba[] } = { type: 'normal' };
|
||||
|
||||
accessor dark$ = computed<ModeTab<ModeType>>(() => this.modes$.value[2]);
|
||||
|
||||
// #ffffff
|
||||
accessor hex6$ = computed(() => this.hex8$.value.substring(0, 7));
|
||||
|
||||
// ffffff
|
||||
accessor hex6WithoutHash$ = computed(() => this.hex6$.value.substring(1));
|
||||
|
||||
// #ffffffff
|
||||
accessor hex8$ = computed(() => rgbaToHex8(this.rgba$.value));
|
||||
|
||||
accessor hsva$ = signal<Hsva>(defaultHsva());
|
||||
|
||||
accessor hue$ = signal<Rgb>({ r: 0, g: 0, b: 0 });
|
||||
|
||||
@query('.color-slider-wrapper.hue .color-slider')
|
||||
accessor hueControl!: HTMLDivElement;
|
||||
|
||||
accessor huePosX$ = signal<number>(0);
|
||||
|
||||
accessor hueStyle$ = computed(() => {
|
||||
const x = this.huePosX$.value;
|
||||
const rgb = this.hue$.value;
|
||||
return {
|
||||
'--x': `${x}px`,
|
||||
'--c': `#${rgbToHex(rgb)}`,
|
||||
'--r': `${SLIDER_CIRCLE_R}px`,
|
||||
};
|
||||
});
|
||||
|
||||
accessor light$ = computed<ModeTab<ModeType>>(() => this.modes$.value[1]);
|
||||
|
||||
accessor mode$ = computed<ModeTab<ModeType>>(() => {
|
||||
const modeType = this.modeType$.value;
|
||||
return this.modes$.value.find(mode => mode.type === modeType)!;
|
||||
});
|
||||
|
||||
accessor modes$ = signal<ModeTab<ModeType>[]>([
|
||||
{ type: 'normal', name: 'Normal', hsva: defaultHsva() },
|
||||
{ type: 'light', name: 'Light', hsva: defaultHsva() },
|
||||
{ type: 'dark', name: 'Dark', hsva: defaultHsva() },
|
||||
]);
|
||||
|
||||
accessor modeType$ = signal<ModeType>('normal');
|
||||
|
||||
accessor navType$ = signal<NavType>('colors');
|
||||
|
||||
@query('.color-palette')
|
||||
accessor paletteControl!: HTMLDivElement;
|
||||
|
||||
accessor palettePos$ = signal<Point>({ x: 0, y: 0 });
|
||||
|
||||
accessor paletteStyle$ = computed(() => {
|
||||
const { x, y } = this.palettePos$.value;
|
||||
const c = this.hex6$.value;
|
||||
return {
|
||||
'--c': c,
|
||||
'--x': `${x}px`,
|
||||
'--y': `${y}px`,
|
||||
'--r': `${AREA_CIRCLE_R}px`,
|
||||
};
|
||||
});
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor pick!: (event: PickColorEvent) => void;
|
||||
|
||||
accessor rgba$ = computed(() => hsvaToRgba(this.hsva$.value));
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-color-picker': EdgelessColorPicker;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Rgb } from './types.js';
|
||||
|
||||
export const AREA_CIRCLE_R = 12.5;
|
||||
export const SLIDER_CIRCLE_R = 10.5;
|
||||
|
||||
// [Rgb, stop][]
|
||||
export const COLORS: [Rgb, number][] = [
|
||||
[{ r: 1, g: 0, b: 0 }, 0 / 6],
|
||||
[{ r: 1, g: 1, b: 0 }, 1 / 6],
|
||||
[{ r: 0, g: 1, b: 0 }, 2 / 6],
|
||||
[{ r: 0, g: 1, b: 1 }, 3 / 6],
|
||||
[{ r: 0, g: 0, b: 1 }, 4 / 6],
|
||||
[{ r: 1, g: 0, b: 1 }, 5 / 6],
|
||||
// eslint-disable-next-line sonarjs/no-identical-expressions
|
||||
[{ r: 1, g: 0, b: 0 }, 6 / 6],
|
||||
];
|
||||
|
||||
export const FIRST_COLOR = COLORS[0][0];
|
||||
|
||||
export const MATCHERS = {
|
||||
hex3: /^#?([0-9a-fA-F]{3})$/,
|
||||
hex6: /^#?([0-9a-fA-F]{6})$/,
|
||||
hex4: /^#?([0-9a-fA-F]{4})$/,
|
||||
hex8: /^#?([0-9a-fA-F]{8})$/,
|
||||
other: /[^0-9a-fA-F]/,
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { colorContainerStyles } from '../panel/color-panel.js';
|
||||
|
||||
export class EdgelessColorCustomButton extends LitElement {
|
||||
static override styles = css`
|
||||
${colorContainerStyles}
|
||||
|
||||
.color-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
padding: 2px;
|
||||
border: 2px solid transparent;
|
||||
background:
|
||||
linear-gradient(var(--c, transparent), var(--c, transparent))
|
||||
content-box,
|
||||
linear-gradient(var(--b, transparent), var(--b, transparent))
|
||||
padding-box,
|
||||
conic-gradient(
|
||||
from 180deg at 50% 50%,
|
||||
#d21c7e 0deg,
|
||||
#c240f0 30.697514712810516deg,
|
||||
#434af5 62.052921652793884deg,
|
||||
#3cb5f9 93.59999656677246deg,
|
||||
#3ceefa 131.40000343322754deg,
|
||||
#37f7bd 167.40000128746033deg,
|
||||
#2df541 203.39999914169312deg,
|
||||
#e7f738 239.40000772476196deg,
|
||||
#fbaf3e 273.07027101516724deg,
|
||||
#fd904e 300.73712825775146deg,
|
||||
#f64545 329.47510957717896deg,
|
||||
#f040a9 359.0167021751404deg
|
||||
)
|
||||
border-box;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="color-container" ?active=${this.active}>
|
||||
<div class="color-unit color-custom"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor active!: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-color-custom-button': EdgelessColorCustomButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './button.js';
|
||||
export * from './color-picker.js';
|
||||
export * from './types.js';
|
||||
@@ -0,0 +1,293 @@
|
||||
import { FONT_SM, FONT_XS } from '@blocksuite/affine-shared/styles';
|
||||
import { css } from 'lit';
|
||||
|
||||
export const COLOR_PICKER_STYLE = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: normal;
|
||||
gap: 12px;
|
||||
min-width: 198px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 8px;
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
nav button {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1 0 0;
|
||||
|
||||
${FONT_XS};
|
||||
color: var(--affine-text-secondary-color);
|
||||
font-weight: 600;
|
||||
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
nav button[active] {
|
||||
color: var(--affine-text-primary-color, #121212);
|
||||
background: var(--affine-background-primary-color);
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modes {
|
||||
display: none;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
}
|
||||
.modes[active] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modes .mode {
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
flex-direction: column;
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
.modes .mode button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 60px;
|
||||
padding: 12px 12px 8px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
box-sizing: border-box;
|
||||
|
||||
${FONT_XS};
|
||||
font-weight: 400;
|
||||
color: #8e8d91;
|
||||
}
|
||||
.modes .mode.light button {
|
||||
background: white;
|
||||
}
|
||||
.modes .mode.dark button {
|
||||
background: #141414;
|
||||
}
|
||||
.modes .mode button .color {
|
||||
background: var(--c);
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modes .mode button[active] {
|
||||
pointer-events: none;
|
||||
outline: 2px solid var(--affine-brand-color, #1e96eb);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.color-palette-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 170px;
|
||||
}
|
||||
|
||||
.color-palette-wrapper canvas {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.color-palette-wrapper::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.color-circle {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
left: calc(-1 * var(--size) / 2);
|
||||
transform: translate(var(--x, 0), var(--y, 0));
|
||||
background: transparent;
|
||||
border: 0.5px solid #e3e2e4;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0px 0px 0px 0.5px #e3e3e4 inset;
|
||||
filter: drop-shadow(0px 0px 12px rgba(66, 65, 73, 0.14));
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.color-circle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--c);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.color-circle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: calc(var(--size) - 1px);
|
||||
height: calc(var(--size) - 1px);
|
||||
background: transparent;
|
||||
border-style: solid;
|
||||
border-color: white;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.color-palette-wrapper {
|
||||
--size: calc(var(--r, 12.5px) * 2);
|
||||
}
|
||||
.color-palette-wrapper .color-circle {
|
||||
top: calc(-1 * var(--size) / 2);
|
||||
}
|
||||
.color-palette-wrapper .color-circle::before {
|
||||
opacity: var(--o, 1);
|
||||
}
|
||||
.color-palette-wrapper .color-circle::after {
|
||||
border-width: 4px;
|
||||
}
|
||||
.color-palette,
|
||||
.color-slider {
|
||||
position: absolute;
|
||||
inset: calc(-1 * var(--size) / 2);
|
||||
}
|
||||
|
||||
.color-slider-wrapper:last-of-type {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.color-slider-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
}
|
||||
.color-slider-wrapper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.color-slider-wrapper {
|
||||
--size: calc(var(--r, 10.5px) * 2);
|
||||
}
|
||||
.color-slider-wrapper .color-circle::after {
|
||||
border-width: 2px;
|
||||
}
|
||||
.color-slider-wrapper.hue::before {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#f00 0%,
|
||||
#ff0 calc(100% / 6),
|
||||
#0f0 calc(200% / 6),
|
||||
#0ff 50%,
|
||||
#00f calc(400% / 6),
|
||||
#f0f calc(500% / 6),
|
||||
#f00 100%
|
||||
);
|
||||
}
|
||||
.color-slider-wrapper.alpha::before {
|
||||
background:
|
||||
linear-gradient(to right, var(--s) 0%, var(--c) 100%),
|
||||
conic-gradient(
|
||||
#fff 25%,
|
||||
#d9d9d9 0deg,
|
||||
#d9d9d9 50%,
|
||||
#fff 0deg,
|
||||
#fff 75%,
|
||||
#d9d9d9 0deg
|
||||
)
|
||||
0% 0% / 8px 8px;
|
||||
}
|
||||
.color-slider-wrapper.alpha .color-circle::before {
|
||||
opacity: var(--o, 1);
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
padding: 7px 9px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
background: var(--affine-background-primary-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field.color {
|
||||
width: 132px;
|
||||
}
|
||||
|
||||
.field.alpha {
|
||||
width: 58px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
${FONT_SM};
|
||||
font-weight: 400;
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
/* Firefox */
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,55 @@
|
||||
// https://www.w3.org/TR/css-color-4/
|
||||
|
||||
import type { ColorScheme } from '@blocksuite/affine-model';
|
||||
|
||||
// Red, green, blue. All in the range [0, 1].
|
||||
export type Rgb = {
|
||||
// red 0-1
|
||||
r: number;
|
||||
// green 0-1
|
||||
g: number;
|
||||
// blue 0-1
|
||||
b: number;
|
||||
};
|
||||
|
||||
// Red, green, blue, alpha. All in the range [0, 1].
|
||||
export type Rgba = Rgb & {
|
||||
// alpha 0-1
|
||||
a: number;
|
||||
};
|
||||
|
||||
// Hue, saturation, value. All in the range [0, 1].
|
||||
export type Hsv = {
|
||||
// hue 0-1
|
||||
h: number;
|
||||
// saturation 0-1
|
||||
s: number;
|
||||
// value 0-1
|
||||
v: number;
|
||||
};
|
||||
|
||||
// Hue, saturation, value, alpha. All in the range [0, 1].
|
||||
export type Hsva = Hsv & {
|
||||
// alpha 0-1
|
||||
a: number;
|
||||
};
|
||||
|
||||
export type Point = { x: number; y: number };
|
||||
|
||||
export type NavType = 'colors' | 'custom';
|
||||
|
||||
export type NavTab<Type> = { type: Type; name: string };
|
||||
|
||||
export type ModeType = 'normal' | `${ColorScheme}`;
|
||||
|
||||
export type ModeTab<Type> = NavTab<Type> & { hsva: Hsva };
|
||||
|
||||
export type ModeRgba = { type: ModeType; rgba: Rgba };
|
||||
|
||||
export type PickColorType = 'palette' | ModeType;
|
||||
|
||||
export type PickColorDetail = Partial<Record<PickColorType, string>>;
|
||||
|
||||
export type PickColorEvent =
|
||||
| { type: 'start' | 'end' }
|
||||
| { type: 'pick'; detail: PickColorDetail };
|
||||
@@ -0,0 +1,308 @@
|
||||
// https://www.w3.org/TR/css-color-4/
|
||||
|
||||
import type { Color, ColorScheme } from '@blocksuite/affine-model';
|
||||
|
||||
import { COLORS, FIRST_COLOR } from './consts.js';
|
||||
import type {
|
||||
Hsv,
|
||||
Hsva,
|
||||
ModeType,
|
||||
PickColorDetail,
|
||||
PickColorType,
|
||||
Point,
|
||||
Rgb,
|
||||
Rgba,
|
||||
} from './types.js';
|
||||
|
||||
export const defaultPoint = (x = 0, y = 0): Point => ({ x, y });
|
||||
|
||||
export const defaultHsva = (): Hsva => ({ ...rgbToHsv(FIRST_COLOR), a: 1 });
|
||||
|
||||
export function linearGradientAt(t: number): Rgb {
|
||||
if (t < 0) return COLORS[0][0];
|
||||
if (t > 1) return COLORS[COLORS.length - 1][0];
|
||||
|
||||
let low = 0;
|
||||
let high = COLORS.length;
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const color = COLORS[mid];
|
||||
if (color[1] < t) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
|
||||
if (low === 0) {
|
||||
low = 1;
|
||||
}
|
||||
|
||||
const [rgb0, s0] = COLORS[low - 1];
|
||||
const [rgb1, s1] = COLORS[low];
|
||||
t = (t - s0) / (s1 - s0);
|
||||
|
||||
const [r, g, b] = [
|
||||
lerp(rgb0.r, rgb1.r, t),
|
||||
lerp(rgb0.g, rgb1.g, t),
|
||||
lerp(rgb0.b, rgb1.b, t),
|
||||
];
|
||||
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + t * (b - a);
|
||||
|
||||
export const clamp = (min: number, val: number, max: number) =>
|
||||
Math.min(Math.max(min, val), max);
|
||||
|
||||
export const bound01 = (n: number, max: number) => {
|
||||
n = clamp(0, n, max);
|
||||
|
||||
// Handle floating point rounding errors
|
||||
if (Math.abs(n - max) < 0.000001) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Convert into [0, 1] range if it isn't already
|
||||
return (n % max) / max;
|
||||
};
|
||||
|
||||
// Converts an RGB color value to HSV
|
||||
export const rgbToHsv = ({ r, g, b }: Rgb): Hsv => {
|
||||
const v = Math.max(r, g, b); // value
|
||||
const d = v - Math.min(r, g, b);
|
||||
|
||||
if (d === 0) {
|
||||
return { h: 0, s: 0, v };
|
||||
}
|
||||
|
||||
const s = d / v;
|
||||
let h = 0;
|
||||
|
||||
if (v === r) {
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
} else if (v === g) {
|
||||
h = (b - r) / d + 2;
|
||||
} else {
|
||||
h = (r - g) / d + 4;
|
||||
}
|
||||
|
||||
h /= 6;
|
||||
|
||||
return { h, s, v };
|
||||
};
|
||||
|
||||
// Converts an HSV color value to RGB
|
||||
export const hsvToRgb = ({ h, s, v }: Hsv): Rgb => {
|
||||
if (h < 0) h = (h + 1) % 1; // wrap
|
||||
h *= 6;
|
||||
s = clamp(0, s, 1);
|
||||
|
||||
const i = Math.floor(h),
|
||||
f = h - i,
|
||||
p = v * (1 - s),
|
||||
q = v * (1 - f * s),
|
||||
t = v * (1 - (1 - f) * s),
|
||||
m = i % 6;
|
||||
|
||||
let rgb = [0, 0, 0];
|
||||
|
||||
if (m === 0) rgb = [v, t, p];
|
||||
else if (m === 1) rgb = [q, v, p];
|
||||
else if (m === 2) rgb = [p, v, t];
|
||||
else if (m === 3) rgb = [p, q, v];
|
||||
else if (m === 4) rgb = [t, p, v];
|
||||
else if (m === 5) rgb = [v, p, q];
|
||||
|
||||
const [r, g, b] = rgb;
|
||||
|
||||
return { r, g, b };
|
||||
};
|
||||
|
||||
// Converts a RGBA color value to HSVA
|
||||
export const rgbaToHsva = (rgba: Rgba): Hsva => ({
|
||||
...rgbToHsv(rgba),
|
||||
a: rgba.a,
|
||||
});
|
||||
|
||||
// Converts an HSVA color value to RGBA
|
||||
export const hsvaToRgba = (hsva: Hsva): Rgba => ({
|
||||
...hsvToRgb(hsva),
|
||||
a: hsva.a,
|
||||
});
|
||||
|
||||
// Converts a RGB color to hex
|
||||
export const rgbToHex = ({ r, g, b }: Rgb) =>
|
||||
[r, g, b]
|
||||
.map(n => n * 255)
|
||||
.map(Math.round)
|
||||
.map(s => s.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
// Converts an RGBA color to CSS's hex8 string
|
||||
export const rgbaToHex8 = ({ r, g, b, a }: Rgba) => {
|
||||
const hex = [r, g, b, a]
|
||||
.map(n => n * 255)
|
||||
.map(Math.round)
|
||||
.map(n => n.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return `#${hex}`;
|
||||
};
|
||||
|
||||
// Converts an HSVA color to CSS's hex8 string
|
||||
export const hsvaToHex8 = (hsva: Hsva) => rgbaToHex8(hsvaToRgba(hsva));
|
||||
|
||||
// Parses an hex string to RGBA.
|
||||
export const parseHexToRgba = (hex: string) => {
|
||||
if (hex.startsWith('#')) {
|
||||
hex = hex.substring(1);
|
||||
}
|
||||
|
||||
const len = hex.length;
|
||||
let arr: string[] = [];
|
||||
|
||||
if (len === 3 || len === 4) {
|
||||
arr = hex.split('').map(s => s.repeat(2));
|
||||
} else if (len === 6 || len === 8) {
|
||||
arr = Array.from<number>({ length: len / 2 })
|
||||
.fill(0)
|
||||
.map((n, i) => n + i * 2)
|
||||
.map(n => hex.substring(n, n + 2));
|
||||
}
|
||||
|
||||
const [r, g, b, a = 1] = arr
|
||||
.map(s => parseInt(s, 16))
|
||||
.map(n => bound01(n, 255));
|
||||
|
||||
return { r, g, b, a };
|
||||
};
|
||||
|
||||
// Parses an hex string to HSVA
|
||||
export const parseHexToHsva = (hex: string) => rgbaToHsva(parseHexToRgba(hex));
|
||||
|
||||
// Compares two hsvs.
|
||||
export const eq = (lhs: Hsv, rhs: Hsv) =>
|
||||
lhs.h === rhs.h && lhs.s === rhs.s && lhs.v === rhs.v;
|
||||
|
||||
export const renderCanvas = (canvas: HTMLCanvasElement, rgb: Rgb) => {
|
||||
const { width, height } = canvas;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
ctx.globalCompositeOperation = 'color';
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Saturation: from top to bottom
|
||||
const s = ctx.createLinearGradient(0, 0, 0, height);
|
||||
s.addColorStop(0, '#0000'); // transparent
|
||||
s.addColorStop(1, '#000'); // black
|
||||
|
||||
ctx.fillStyle = s;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Value: from left to right
|
||||
const v = ctx.createLinearGradient(0, 0, width, 0);
|
||||
v.addColorStop(0, '#fff'); // white
|
||||
v.addColorStop(1, `#${rgbToHex(rgb)}`); // picked color
|
||||
|
||||
ctx.fillStyle = v;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
};
|
||||
|
||||
// Drops alpha value
|
||||
export const keepColor = (color: string) =>
|
||||
color.length > 7 && !color.endsWith('transparent')
|
||||
? color.substring(0, 7)
|
||||
: color;
|
||||
|
||||
export const parseStringToRgba = (value: string) => {
|
||||
value = value.trim();
|
||||
|
||||
// Compatible old format: `--affine-palette-transparent`
|
||||
if (value.endsWith('transparent')) {
|
||||
return { r: 1, g: 1, b: 1, a: 0 };
|
||||
}
|
||||
|
||||
if (value.startsWith('#')) {
|
||||
return parseHexToRgba(value);
|
||||
}
|
||||
|
||||
if (value.startsWith('rgb')) {
|
||||
const [r, g, b, a = 1] = value
|
||||
.replace(/^rgba?/, '')
|
||||
.replace(/\(|\)/, '')
|
||||
.split(',')
|
||||
.map(s => parseFloat(s.trim()))
|
||||
// In CSS, the alpha is already in the range [0, 1]
|
||||
.map((n, i) => bound01(n, i === 3 ? 1 : 255));
|
||||
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
return { r: 0, g: 0, b: 0, a: 1 };
|
||||
};
|
||||
|
||||
// Preprocess Color
|
||||
export const preprocessColor = (style: CSSStyleDeclaration) => {
|
||||
return ({ type, value }: { type: ModeType; value: string }) => {
|
||||
if (value.startsWith('--')) {
|
||||
// Compatible old format: `--affine-palette-transparent`
|
||||
value = value.endsWith('transparent')
|
||||
? 'transparent'
|
||||
: style.getPropertyValue(value);
|
||||
}
|
||||
|
||||
const rgba = parseStringToRgba(value);
|
||||
|
||||
return { type, rgba };
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Packs to generate an object with a field name and picked color detail
|
||||
*
|
||||
* @param key - The model's field name
|
||||
* @param detail - The picked color detail
|
||||
* @returns An object
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```json
|
||||
* { 'fillColor': '--affine-palette-shape-yellow' }
|
||||
* { 'fillColor': { normal: '#ffffffff' }}
|
||||
* { 'fillColor': { light: '#fff000ff', 'dark': '#0000fff00' }}
|
||||
* ```
|
||||
*/
|
||||
export const packColor = (key: string, detail: PickColorDetail) => {
|
||||
return { [key]: detail.palette ?? detail };
|
||||
};
|
||||
|
||||
/**
|
||||
* Packs to generate a color array with the color-scheme
|
||||
*
|
||||
* @param colorScheme - The current color theme
|
||||
* @param value - The color value
|
||||
* @param oldColor - The old color
|
||||
* @returns A color array
|
||||
*/
|
||||
export const packColorsWithColorScheme = (
|
||||
colorScheme: ColorScheme,
|
||||
value: string,
|
||||
oldColor: Color
|
||||
) => {
|
||||
const colors: { type: ModeType; value: string }[] = [
|
||||
{ type: 'normal', value },
|
||||
{ type: 'light', value },
|
||||
{ type: 'dark', value },
|
||||
];
|
||||
let type: PickColorType = 'palette';
|
||||
|
||||
if (typeof oldColor === 'object') {
|
||||
type = colorScheme in oldColor ? colorScheme : 'normal';
|
||||
colors[0].value = oldColor.normal ?? value;
|
||||
colors[1].value = oldColor.light ?? value;
|
||||
colors[2].value = oldColor.dark ?? value;
|
||||
}
|
||||
|
||||
return { type, colors };
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
type ConnectionOverlay,
|
||||
OverlayIdentifier,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { ConnectorElementModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
type BlockStdScope,
|
||||
docContext,
|
||||
stdContext,
|
||||
} from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import { DisposableGroup, Vec, WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { Doc } from '@blocksuite/store';
|
||||
import { consume } from '@lit/context';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
|
||||
const SIZE = 12;
|
||||
const HALF_SIZE = SIZE / 2;
|
||||
|
||||
export class EdgelessConnectorHandle extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.line-controller {
|
||||
position: absolute;
|
||||
width: ${SIZE}px;
|
||||
height: ${SIZE}px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--affine-text-emphasis-color);
|
||||
background-color: var(--affine-background-primary-color);
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
pointer-events: all;
|
||||
/**
|
||||
* Fix: pointerEvent stops firing after a short time.
|
||||
* When a gesture is started, the browser intersects the touch-action values of the touched element and its ancestors,
|
||||
* up to the one that implements the gesture (in other words, the first containing scrolling element)
|
||||
* https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action
|
||||
*/
|
||||
touch-action: none;
|
||||
}
|
||||
.line-controller-hidden {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
private _lastZoom = 1;
|
||||
|
||||
get connectionOverlay() {
|
||||
return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay;
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
private _bindEvent() {
|
||||
const edgeless = this.edgeless;
|
||||
|
||||
this._disposables.addFromEvent(this._startHandler, 'pointerdown', e => {
|
||||
edgeless.slots.elementResizeStart.emit();
|
||||
this._capPointerDown(e, 'source');
|
||||
});
|
||||
this._disposables.addFromEvent(this._endHandler, 'pointerdown', e => {
|
||||
edgeless.slots.elementResizeStart.emit();
|
||||
this._capPointerDown(e, 'target');
|
||||
});
|
||||
this._disposables.add(() => {
|
||||
this.connectionOverlay.clear();
|
||||
});
|
||||
}
|
||||
|
||||
private _capPointerDown(e: PointerEvent, connection: 'target' | 'source') {
|
||||
const { edgeless, connector, _disposables } = this;
|
||||
const { service } = edgeless;
|
||||
e.stopPropagation();
|
||||
_disposables.addFromEvent(document, 'pointermove', e => {
|
||||
const point = service.viewport.toModelCoordFromClientCoord([e.x, e.y]);
|
||||
const isStartPointer = connection === 'source';
|
||||
const otherSideId = connector[isStartPointer ? 'target' : 'source'].id;
|
||||
|
||||
connector[connection] = this.connectionOverlay.renderConnector(
|
||||
point,
|
||||
otherSideId ? [otherSideId] : []
|
||||
);
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
_disposables.addFromEvent(document, 'pointerup', () => {
|
||||
this.doc.captureSync();
|
||||
_disposables.dispose();
|
||||
this._disposables = new DisposableGroup();
|
||||
this._bindEvent();
|
||||
edgeless.slots.elementResizeEnd.emit();
|
||||
});
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { edgeless } = this;
|
||||
const { viewport } = edgeless.service;
|
||||
|
||||
this._lastZoom = viewport.zoom;
|
||||
edgeless.service.viewport.viewportUpdated.on(() => {
|
||||
if (viewport.zoom !== this._lastZoom) {
|
||||
this._lastZoom = viewport.zoom;
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
this._bindEvent();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { service } = this.edgeless;
|
||||
// path is relative to the element's xywh
|
||||
const { path } = this.connector;
|
||||
const zoom = service.viewport.zoom;
|
||||
const startPoint = Vec.subScalar(Vec.mul(path[0], zoom), HALF_SIZE);
|
||||
const endPoint = Vec.subScalar(
|
||||
Vec.mul(path[path.length - 1], zoom),
|
||||
HALF_SIZE
|
||||
);
|
||||
const startStyle = {
|
||||
transform: `translate3d(${startPoint[0]}px,${startPoint[1]}px,0)`,
|
||||
};
|
||||
const endStyle = {
|
||||
transform: `translate3d(${endPoint[0]}px,${endPoint[1]}px,0)`,
|
||||
};
|
||||
return html`
|
||||
<div
|
||||
class="line-controller line-start"
|
||||
style=${styleMap(startStyle)}
|
||||
></div>
|
||||
<div class="line-controller line-end" style=${styleMap(endStyle)}></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.line-end')
|
||||
private accessor _endHandler!: HTMLDivElement;
|
||||
|
||||
@query('.line-start')
|
||||
private accessor _startHandler!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor connector!: ConnectorElementModel;
|
||||
|
||||
@consume({
|
||||
context: docContext,
|
||||
})
|
||||
accessor doc!: Doc;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@consume({
|
||||
context: stdContext,
|
||||
})
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-connector-handle': EdgelessConnectorHandle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { FrameBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockServiceWatcher,
|
||||
BlockStdScope,
|
||||
type EditorHost,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import {
|
||||
Bound,
|
||||
debounce,
|
||||
deserializeXYWH,
|
||||
DisposableGroup,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { BlockViewType, type Doc, type Query } from '@blocksuite/store';
|
||||
import { css, html, nothing, type PropertyValues } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { SpecProvider } from '../../../../_specs/index.js';
|
||||
import type { EdgelessRootPreviewBlockComponent } from '../../edgeless-root-preview-block.js';
|
||||
import type { EdgelessRootService } from '../../edgeless-root-service.js';
|
||||
|
||||
const DEFAULT_PREVIEW_CONTAINER_WIDTH = 280;
|
||||
const DEFAULT_PREVIEW_CONTAINER_HEIGHT = 166;
|
||||
|
||||
const styles = css`
|
||||
.frame-preview-container {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.frame-preview-surface-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.frame-preview-viewport {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
.edgeless-background {
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export class FramePreview extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private _clearFrameDisposables = () => {
|
||||
this._frameDisposables?.dispose();
|
||||
this._frameDisposables = null;
|
||||
};
|
||||
|
||||
private _docFilter: Query = {
|
||||
mode: 'loose',
|
||||
match: [
|
||||
{
|
||||
flavour: 'affine:frame',
|
||||
viewType: BlockViewType.Hidden,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
private _frameDisposables: DisposableGroup | null = null;
|
||||
|
||||
private _previewDoc: Doc | null = null;
|
||||
|
||||
private _previewSpec = SpecProvider.getInstance().getSpec('edgeless:preview');
|
||||
|
||||
private _updateFrameViewportWH = () => {
|
||||
const [, , w, h] = deserializeXYWH(this.frame.xywh);
|
||||
|
||||
let scale = 1;
|
||||
if (this.fillScreen) {
|
||||
scale = Math.max(this.surfaceWidth / w, this.surfaceHeight / h);
|
||||
} else {
|
||||
scale = Math.min(this.surfaceWidth / w, this.surfaceHeight / h);
|
||||
}
|
||||
|
||||
this.frameViewportWH = {
|
||||
width: w * scale,
|
||||
height: h * scale,
|
||||
};
|
||||
};
|
||||
|
||||
get _originalDoc() {
|
||||
return this.frame.doc;
|
||||
}
|
||||
|
||||
private _initPreviewDoc() {
|
||||
this._previewDoc = this._originalDoc.collection.getDoc(
|
||||
this._originalDoc.id,
|
||||
{
|
||||
query: this._docFilter,
|
||||
readonly: true,
|
||||
}
|
||||
);
|
||||
this.disposables.add(() => {
|
||||
this._originalDoc.blockCollection.clearQuery(this._docFilter);
|
||||
});
|
||||
}
|
||||
|
||||
private _initSpec() {
|
||||
const refreshViewport = this._refreshViewport.bind(this);
|
||||
class FramePreviewWatcher extends BlockServiceWatcher {
|
||||
static override readonly flavour = 'affine:page';
|
||||
|
||||
override mounted() {
|
||||
const blockService = this.blockService;
|
||||
blockService.disposables.add(
|
||||
blockService.specSlots.viewConnected.on(({ component }) => {
|
||||
const edgelessBlock =
|
||||
component as EdgelessRootPreviewBlockComponent;
|
||||
|
||||
edgelessBlock.editorViewportSelector = 'frame-preview-viewport';
|
||||
edgelessBlock.service.viewport.sizeUpdated.once(() => {
|
||||
refreshViewport();
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
this._previewSpec.extend([FramePreviewWatcher]);
|
||||
}
|
||||
|
||||
private _refreshViewport() {
|
||||
const previewEditorHost = this.previewEditor;
|
||||
|
||||
if (!previewEditorHost) return;
|
||||
|
||||
const edgelessService = previewEditorHost.std.getService(
|
||||
'affine:page'
|
||||
) as EdgelessRootService;
|
||||
|
||||
const frameBound = Bound.deserialize(this.frame.xywh);
|
||||
edgelessService.viewport.setViewportByBound(frameBound);
|
||||
}
|
||||
|
||||
private _renderSurfaceContent() {
|
||||
if (!this._previewDoc || !this.frame) return nothing;
|
||||
const { width, height } = this.frameViewportWH;
|
||||
|
||||
const _previewSpec = this._previewSpec.value;
|
||||
return html`<div
|
||||
class="frame-preview-surface-container"
|
||||
style=${styleMap({
|
||||
width: `${this.surfaceWidth}px`,
|
||||
height: `${this.surfaceHeight}px`,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
class="frame-preview-viewport"
|
||||
style=${styleMap({
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
})}
|
||||
>
|
||||
${new BlockStdScope({
|
||||
doc: this._previewDoc,
|
||||
extensions: _previewSpec,
|
||||
}).render()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _setFrameDisposables(frame: FrameBlockModel) {
|
||||
this._clearFrameDisposables();
|
||||
this._frameDisposables = new DisposableGroup();
|
||||
this._frameDisposables.add(
|
||||
frame.propsUpdated.on(debounce(this._updateFrameViewportWH, 10))
|
||||
);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._initSpec();
|
||||
this._initPreviewDoc();
|
||||
this._updateFrameViewportWH();
|
||||
this._setFrameDisposables(this.frame);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._clearFrameDisposables();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { frame } = this;
|
||||
const noContent = !frame || !frame.xywh;
|
||||
|
||||
return html`<div class="frame-preview-container">
|
||||
${noContent ? nothing : this._renderSurfaceContent()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override updated(_changedProperties: PropertyValues) {
|
||||
if (_changedProperties.has('frame')) {
|
||||
this._setFrameDisposables(this.frame);
|
||||
}
|
||||
if (_changedProperties.has('frameViewportWH')) {
|
||||
this._refreshViewport();
|
||||
}
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor fillScreen = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor frame!: FrameBlockModel;
|
||||
|
||||
@state()
|
||||
accessor frameViewportWH = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
@query('editor-host')
|
||||
accessor previewEditor: EditorHost | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor surfaceHeight: number = DEFAULT_PREVIEW_CONTAINER_HEIGHT;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor surfaceWidth: number = DEFAULT_PREVIEW_CONTAINER_WIDTH;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frame-preview': FramePreview;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import { SmallScissorsIcon } from '@blocksuite/affine-components/icons';
|
||||
import { DEFAULT_NOTE_HEIGHT } from '@blocksuite/affine-model';
|
||||
import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import { getRectByBlockComponent } from '@blocksuite/affine-shared/utils';
|
||||
import { WidgetComponent } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
deserializeXYWH,
|
||||
DisposableGroup,
|
||||
Point,
|
||||
serializeXYWH,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { css, html, nothing, type PropertyValues } 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 {
|
||||
EdgelessRootBlockComponent,
|
||||
NoteBlockComponent,
|
||||
NoteBlockModel,
|
||||
RootBlockModel,
|
||||
} from '../../../../index.js';
|
||||
import { isNoteBlock } from '../../utils/query.js';
|
||||
|
||||
const DIVIDING_LINE_OFFSET = 4;
|
||||
const NEW_NOTE_GAP = 40;
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.note-slicer-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.note-slicer-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--affine-icon-color);
|
||||
border: 1px solid var(--affine-border-color);
|
||||
background-color: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-menu-shadow);
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform-origin: left top;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
}
|
||||
|
||||
.note-slicer-dividing-line-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note-slicer-dividing-line {
|
||||
display: block;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--affine-black-10) 50%,
|
||||
transparent 50%
|
||||
);
|
||||
background-size: 4px 100%;
|
||||
}
|
||||
.note-slicer-dividing-line-container.active .note-slicer-dividing-line {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--affine-black-60) 50%,
|
||||
transparent 50%
|
||||
);
|
||||
animation: slide 0.3s linear infinite;
|
||||
}
|
||||
@keyframes slide {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -4px 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const NOTE_SLICER_WIDGET = 'note-slicer';
|
||||
|
||||
export class NoteSlicer extends WidgetComponent<
|
||||
RootBlockModel,
|
||||
EdgelessRootBlockComponent
|
||||
> {
|
||||
static override styles = styles;
|
||||
|
||||
private _divingLinePositions: Point[] = [];
|
||||
|
||||
private _hidden = false;
|
||||
|
||||
private _noteBlockIds: string[] = [];
|
||||
|
||||
private _noteDisposables: DisposableGroup | null = null;
|
||||
|
||||
get _editorHost() {
|
||||
return this.std.host;
|
||||
}
|
||||
|
||||
get _noteBlock() {
|
||||
if (!this._editorHost) return null;
|
||||
const noteBlock = this._editorHost.view.getBlock(
|
||||
this._anchorNote?.id ?? ''
|
||||
);
|
||||
return noteBlock ? (noteBlock as NoteBlockComponent) : null;
|
||||
}
|
||||
|
||||
get _selection() {
|
||||
return this.gfx.selection;
|
||||
}
|
||||
|
||||
get _viewportOffset() {
|
||||
const { viewport } = this.gfx;
|
||||
return {
|
||||
left: viewport.left ?? 0,
|
||||
top: viewport.top ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
get _zoom() {
|
||||
return this.gfx.viewport.zoom;
|
||||
}
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get selectedRectEle() {
|
||||
return this.block.selectedRectWidget;
|
||||
}
|
||||
|
||||
private _sliceNote() {
|
||||
if (!this._anchorNote || !this._noteBlockIds.length) return;
|
||||
const doc = this.doc;
|
||||
|
||||
const {
|
||||
index: originIndex,
|
||||
xywh,
|
||||
background,
|
||||
children,
|
||||
displayMode,
|
||||
} = this._anchorNote;
|
||||
const {
|
||||
collapse: _,
|
||||
collapsedHeight: __,
|
||||
...restOfEdgeless
|
||||
} = this._anchorNote.edgeless;
|
||||
const anchorBlockId = this._noteBlockIds[this._activeSlicerIndex];
|
||||
if (!anchorBlockId) return;
|
||||
const sliceIndex = children.findIndex(block => block.id === anchorBlockId);
|
||||
const resetBlocks = children.slice(sliceIndex + 1);
|
||||
const [x, , width] = deserializeXYWH(xywh);
|
||||
const sliceVerticalPos =
|
||||
this._divingLinePositions[this._activeSlicerIndex].y;
|
||||
const newY = this.gfx.viewport.toModelCoord(x, sliceVerticalPos)[1];
|
||||
const newNoteId = this.doc.addBlock(
|
||||
'affine:note',
|
||||
{
|
||||
background,
|
||||
displayMode,
|
||||
xywh: serializeXYWH(x, newY + NEW_NOTE_GAP, width, DEFAULT_NOTE_HEIGHT),
|
||||
index: originIndex + 1,
|
||||
edgeless: restOfEdgeless,
|
||||
},
|
||||
doc.root?.id
|
||||
);
|
||||
|
||||
doc.moveBlocks(resetBlocks, doc.getBlockById(newNoteId) as NoteBlockModel);
|
||||
|
||||
this._activeSlicerIndex = 0;
|
||||
this._selection.set({
|
||||
elements: [newNoteId],
|
||||
editing: false,
|
||||
});
|
||||
|
||||
this.std.getOptional(TelemetryProvider)?.track('SplitNote', {
|
||||
control: 'NoteSlicer',
|
||||
});
|
||||
}
|
||||
|
||||
private _updateActiveSlicerIndex(pos: Point) {
|
||||
const { _divingLinePositions } = this;
|
||||
const curY = pos.y + DIVIDING_LINE_OFFSET * this._zoom;
|
||||
let index = -1;
|
||||
for (let i = 0; i < _divingLinePositions.length; i++) {
|
||||
const currentY = _divingLinePositions[i].y;
|
||||
const previousY = i > 0 ? _divingLinePositions[i - 1].y : 0;
|
||||
const midY = (currentY + previousY) / 2;
|
||||
if (curY < midY) {
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index < 0) index = 0;
|
||||
this._activeSlicerIndex = index;
|
||||
}
|
||||
|
||||
private _updateDivingLineAndBlockIds() {
|
||||
if (!this._anchorNote || !this._noteBlock) {
|
||||
this._divingLinePositions = [];
|
||||
this._noteBlockIds = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const divingLinePositions: Point[] = [];
|
||||
const noteBlockIds: string[] = [];
|
||||
const noteRect = this._noteBlock.getBoundingClientRect();
|
||||
const noteTop = noteRect.top;
|
||||
const noteBottom = noteRect.bottom;
|
||||
|
||||
for (let i = 0; i < this._anchorNote.children.length - 1; i++) {
|
||||
const child = this._anchorNote.children[i];
|
||||
const rect = this.host.view.getBlock(child.id)?.getBoundingClientRect();
|
||||
|
||||
if (rect && rect.bottom > noteTop && rect.bottom < noteBottom) {
|
||||
const x = rect.x - this._viewportOffset.left;
|
||||
const y =
|
||||
rect.bottom +
|
||||
DIVIDING_LINE_OFFSET * this._zoom -
|
||||
this._viewportOffset.top;
|
||||
divingLinePositions.push(new Point(x, y));
|
||||
noteBlockIds.push(child.id);
|
||||
}
|
||||
}
|
||||
|
||||
this._divingLinePositions = divingLinePositions;
|
||||
this._noteBlockIds = noteBlockIds;
|
||||
}
|
||||
|
||||
private _updateSlicedNote() {
|
||||
const { selectedElements } = this.gfx.selection;
|
||||
|
||||
if (
|
||||
!this.gfx.selection.editing &&
|
||||
selectedElements.length === 1 &&
|
||||
isNoteBlock(selectedElements[0])
|
||||
) {
|
||||
this._anchorNote = selectedElements[0];
|
||||
} else {
|
||||
this._anchorNote = null;
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
const { disposables, std, block, gfx } = this;
|
||||
|
||||
this._updateDivingLineAndBlockIds();
|
||||
|
||||
disposables.add(
|
||||
block.slots.elementResizeStart.on(() => {
|
||||
this._isResizing = true;
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
block.slots.elementResizeEnd.on(() => {
|
||||
this._isResizing = false;
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
std.event.add('pointerMove', ctx => {
|
||||
if (this._hidden) this._hidden = false;
|
||||
|
||||
const state = ctx.get('pointerState');
|
||||
const pos = new Point(state.x, state.y);
|
||||
this._updateActiveSlicerIndex(pos);
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
gfx.viewport.viewportUpdated.on(() => {
|
||||
this._hidden = true;
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
gfx.selection.slots.updated.on(() => {
|
||||
this._enableNoteSlicer = false;
|
||||
this._updateSlicedNote();
|
||||
|
||||
if (this.selectedRectEle) {
|
||||
this.selectedRectEle.autoCompleteOff = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
block.slots.toggleNoteSlicer.on(() => {
|
||||
this._enableNoteSlicer = !this._enableNoteSlicer;
|
||||
|
||||
if (this.selectedRectEle && this._enableNoteSlicer) {
|
||||
this.selectedRectEle.autoCompleteOff = true;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const { surface } = block;
|
||||
requestAnimationFrame(() => {
|
||||
if (surface.isConnected && std.event) {
|
||||
disposables.add(
|
||||
std.event.add('click', ctx => {
|
||||
const event = ctx.get('pointerState');
|
||||
const { raw } = event;
|
||||
const target = raw.target as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
if (target.closest('note-slicer')) {
|
||||
this._sliceNote();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.disposables.dispose();
|
||||
this._noteDisposables?.dispose();
|
||||
this._noteDisposables = null;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
if (!this.block.service) return;
|
||||
this.disposables.add(
|
||||
this.block.service.uiEventDispatcher.add('wheel', () => {
|
||||
this._hidden = true;
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (
|
||||
this.doc.readonly ||
|
||||
this._hidden ||
|
||||
this._isResizing ||
|
||||
!this._anchorNote ||
|
||||
!this._enableNoteSlicer
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
this._updateDivingLineAndBlockIds();
|
||||
|
||||
const noteBlock = this._noteBlock;
|
||||
if (!noteBlock || !this._divingLinePositions.length) return nothing;
|
||||
|
||||
const rect = getRectByBlockComponent(noteBlock);
|
||||
const width = rect.width - 2 * EDGELESS_BLOCK_CHILD_PADDING;
|
||||
const buttonPosition = this._divingLinePositions[this._activeSlicerIndex];
|
||||
|
||||
return html`<div class="note-slicer-container">
|
||||
<div
|
||||
class="note-slicer-button"
|
||||
style=${styleMap({
|
||||
left: `${buttonPosition.x - 66 * this._zoom}px`,
|
||||
top: `${buttonPosition.y}px`,
|
||||
opacity: 1,
|
||||
scale: `${this._zoom}`,
|
||||
transform: 'translateY(-50%)',
|
||||
})}
|
||||
>
|
||||
${SmallScissorsIcon}
|
||||
</div>
|
||||
${this._divingLinePositions.map((pos, idx) => {
|
||||
const dividingLineClasses = classMap({
|
||||
'note-slicer-dividing-line-container': true,
|
||||
active: idx === this._activeSlicerIndex,
|
||||
});
|
||||
return html`<div
|
||||
class=${dividingLineClasses}
|
||||
style=${styleMap({
|
||||
left: `${pos.x}px`,
|
||||
top: `${pos.y}px`,
|
||||
width: `${width}px`,
|
||||
})}
|
||||
>
|
||||
<span class="note-slicer-dividing-line"></span>
|
||||
</div>`;
|
||||
})}
|
||||
</div> `;
|
||||
}
|
||||
|
||||
protected override updated(_changedProperties: PropertyValues) {
|
||||
super.updated(_changedProperties);
|
||||
if (_changedProperties.has('anchorNote')) {
|
||||
this._noteDisposables?.dispose();
|
||||
this._noteDisposables = null;
|
||||
if (this._anchorNote) {
|
||||
this._noteDisposables = new DisposableGroup();
|
||||
this._noteDisposables.add(
|
||||
this._anchorNote.propsUpdated.on(({ key }) => {
|
||||
if (key === 'children' || key === 'xywh') {
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _activeSlicerIndex = 0;
|
||||
|
||||
@state()
|
||||
private accessor _anchorNote: NoteBlockModel | null = null;
|
||||
|
||||
@state()
|
||||
private accessor _enableNoteSlicer = false;
|
||||
|
||||
@state()
|
||||
private accessor _isResizing = false;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'note-slicer': NoteSlicer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { TextAlign } from '@blocksuite/affine-model';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
const TEXT_ALIGN_LIST = [
|
||||
{
|
||||
name: 'Left',
|
||||
value: TextAlign.Left,
|
||||
icon: TextAlignLeftIcon,
|
||||
},
|
||||
{
|
||||
name: 'Center',
|
||||
value: TextAlign.Center,
|
||||
icon: TextAlignCenterIcon,
|
||||
},
|
||||
{
|
||||
name: 'Right',
|
||||
value: TextAlign.Right,
|
||||
icon: TextAlignRightIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export class EdgelessAlignPanel extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
private _onSelect(value: TextAlign) {
|
||||
this.value = value;
|
||||
if (this.onSelect) {
|
||||
this.onSelect(value);
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return repeat(
|
||||
TEXT_ALIGN_LIST,
|
||||
item => item.name,
|
||||
({ name, value, icon }) => html`
|
||||
<edgeless-tool-icon-button
|
||||
.activeMode=${'background'}
|
||||
aria-label=${name}
|
||||
.tooltip=${name}
|
||||
.active=${this.value === value}
|
||||
@click=${() => this._onSelect(value)}
|
||||
>
|
||||
${icon}
|
||||
</edgeless-tool-icon-button>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSelect: undefined | ((value: TextAlign) => void) = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor value: TextAlign = TextAlign.Left;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-align-panel': EdgelessAlignPanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type { EmbedCardStyle } from '../../../../_common/types.js';
|
||||
|
||||
export class CardStylePanel extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
icon-button {
|
||||
padding: var(--1, 0px);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
icon-button.selected {
|
||||
border: 1px solid var(--affine-brand-color);
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
const options = this.options;
|
||||
if (!options?.length) return nothing;
|
||||
|
||||
return repeat(
|
||||
options,
|
||||
options => options.style,
|
||||
({ style, Icon, tooltip }) => html`
|
||||
<icon-button
|
||||
width="76px"
|
||||
height="76px"
|
||||
class=${classMap({
|
||||
selected: this.value === style,
|
||||
})}
|
||||
@click=${() => {
|
||||
this.onSelect(style);
|
||||
this.value = style;
|
||||
}}
|
||||
>
|
||||
${Icon}
|
||||
<affine-tooltip .offset=${4}>${tooltip}</affine-tooltip>
|
||||
</icon-button>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSelect!: (value: EmbedCardStyle) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor options!: {
|
||||
style: EmbedCardStyle;
|
||||
Icon: TemplateResult<1>;
|
||||
tooltip: string;
|
||||
}[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor value: EmbedCardStyle | undefined = undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'card-style-panel': CardStylePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { TransparentIcon } from '@blocksuite/affine-components/icons';
|
||||
import {
|
||||
ColorScheme,
|
||||
LINE_COLORS,
|
||||
LineColor,
|
||||
NoteBackgroundColor,
|
||||
ShapeFillColor,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export class ColorEvent extends Event {
|
||||
detail: string;
|
||||
|
||||
constructor(
|
||||
type: string,
|
||||
{
|
||||
detail,
|
||||
composed,
|
||||
bubbles,
|
||||
}: { detail: string; composed: boolean; bubbles: boolean }
|
||||
) {
|
||||
super(type, { bubbles, composed });
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET_DEFAULT_LINE_COLOR = (theme: ColorScheme) => {
|
||||
return theme === ColorScheme.Dark ? LineColor.White : LineColor.Black;
|
||||
};
|
||||
|
||||
export function isTransparent(color: string) {
|
||||
return color.toLowerCase().endsWith('transparent');
|
||||
}
|
||||
|
||||
function isSameColorWithBackground(color: string) {
|
||||
const colors: string[] = [
|
||||
LineColor.Black,
|
||||
LineColor.White,
|
||||
NoteBackgroundColor.Black,
|
||||
NoteBackgroundColor.White,
|
||||
ShapeFillColor.Black,
|
||||
ShapeFillColor.White,
|
||||
];
|
||||
return colors.includes(color.toLowerCase());
|
||||
}
|
||||
|
||||
function TransparentColor(hollowCircle = false) {
|
||||
const containerStyle = {
|
||||
position: 'relative',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
stroke: 'none',
|
||||
};
|
||||
const maskStyle = {
|
||||
position: 'absolute',
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
left: '3px',
|
||||
top: '3.5px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--affine-background-overlay-panel-color)',
|
||||
};
|
||||
|
||||
const mask = hollowCircle
|
||||
? html`<div style=${styleMap(maskStyle)}></div>`
|
||||
: nothing;
|
||||
|
||||
return html`
|
||||
<div style=${styleMap(containerStyle)}>${TransparentIcon} ${mask}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function BorderedHollowCircle(color: string) {
|
||||
const valid = color.startsWith('--');
|
||||
const strokeWidth = valid && isSameColorWithBackground(color) ? 1 : 0;
|
||||
const style = {
|
||||
fill: valid ? `var(${color})` : color,
|
||||
stroke: 'var(--affine-border-color)',
|
||||
};
|
||||
return html`
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.3125 8C12.3125 10.3817 10.3817 12.3125 8 12.3125C5.61827 12.3125 3.6875 10.3817 3.6875 8C3.6875 5.61827 5.61827 3.6875 8 3.6875C10.3817 3.6875 12.3125 5.61827 12.3125 8ZM8 15.5C12.1421 15.5 15.5 12.1421 15.5 8C15.5 3.85786 12.1421 0.5 8 0.5C3.85786 0.5 0.5 3.85786 0.5 8C0.5 12.1421 3.85786 15.5 8 15.5Z"
|
||||
stroke-width="${strokeWidth}"
|
||||
style=${styleMap(style)}
|
||||
/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
function AdditionIcon(color: string, hollowCircle: boolean) {
|
||||
if (isTransparent(color)) {
|
||||
return TransparentColor(hollowCircle);
|
||||
}
|
||||
if (hollowCircle) {
|
||||
return BorderedHollowCircle(color);
|
||||
}
|
||||
return nothing;
|
||||
}
|
||||
|
||||
export function ColorUnit(
|
||||
color: string,
|
||||
{
|
||||
hollowCircle,
|
||||
letter,
|
||||
}: {
|
||||
hollowCircle?: boolean;
|
||||
letter?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const additionIcon = AdditionIcon(color, !!hollowCircle);
|
||||
|
||||
const colorStyle =
|
||||
!hollowCircle && !isTransparent(color)
|
||||
? { background: `var(${color})` }
|
||||
: {};
|
||||
|
||||
const borderStyle =
|
||||
isSameColorWithBackground(color) && !hollowCircle
|
||||
? {
|
||||
border: '0.5px solid var(--affine-border-color)',
|
||||
}
|
||||
: {};
|
||||
|
||||
const style = {
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
...borderStyle,
|
||||
...colorStyle,
|
||||
};
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="color-unit"
|
||||
style=${styleMap(style)}
|
||||
aria-label=${color.toLowerCase()}
|
||||
data-letter=${letter ? 'A' : ''}
|
||||
>
|
||||
${additionIcon}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export class EdgelessColorButton extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.color-unit {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
get preprocessColor() {
|
||||
const color = this.color;
|
||||
return color.startsWith('--') ? `var(${color})` : color;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { color, hollowCircle, letter } = this;
|
||||
const additionIcon = AdditionIcon(color, !!hollowCircle);
|
||||
const style: Record<string, string> = {};
|
||||
if (!hollowCircle) {
|
||||
style.background = this.preprocessColor;
|
||||
if (isSameColorWithBackground(color)) {
|
||||
style.border = '0.5px solid var(--affine-border-color)';
|
||||
}
|
||||
}
|
||||
return html`<div
|
||||
class="color-unit"
|
||||
aria-label=${color.toLowerCase()}
|
||||
data-letter=${letter ? 'A' : nothing}
|
||||
style=${styleMap(style)}
|
||||
>
|
||||
${additionIcon}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor color!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hollowCircle: boolean | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor letter: boolean | undefined = undefined;
|
||||
}
|
||||
|
||||
export const colorContainerStyles = css`
|
||||
.color-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.color-unit::before {
|
||||
content: attr(data-letter);
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.color-container[active]:after {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 0.5px solid var(--affine-primary-color);
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
content: attr(data-letter);
|
||||
}
|
||||
`;
|
||||
|
||||
export class EdgelessColorPanel extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
width: 184px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
${colorContainerStyles}
|
||||
`;
|
||||
|
||||
get palettes() {
|
||||
return this.hasTransparent
|
||||
? ['--affine-palette-transparent', ...this.options]
|
||||
: this.options;
|
||||
}
|
||||
|
||||
onSelect(value: string) {
|
||||
this.dispatchEvent(
|
||||
new ColorEvent('select', {
|
||||
detail: value,
|
||||
composed: true,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${repeat(
|
||||
this.palettes,
|
||||
color => color,
|
||||
color => {
|
||||
const unit = ColorUnit(color, {
|
||||
hollowCircle: this.hollowCircle,
|
||||
letter: this.showLetterMark,
|
||||
});
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="color-container"
|
||||
?active=${color === this.value}
|
||||
@click=${() => this.onSelect(color)}
|
||||
>
|
||||
${unit}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<slot name="custom"></slot>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hasTransparent: boolean = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hollowCircle = false;
|
||||
|
||||
@property()
|
||||
accessor openColorPicker!: (e: MouseEvent) => void;
|
||||
|
||||
@property({ type: Array })
|
||||
accessor options: readonly string[] = LINE_COLORS;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showLetterMark = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor value: string | null = null;
|
||||
}
|
||||
|
||||
export class EdgelessTextColorIcon extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
get preprocessColor() {
|
||||
const color = this.color;
|
||||
return color.startsWith('--') ? `var(${color})` : color;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M8.71093 3.85123C8.91241 3.31395 9.42603 2.95801 9.99984 2.95801C10.5737 2.95801 11.0873 3.31395 11.2888 3.85123L14.7517 13.0858C14.8729 13.409 14.7092 13.7692 14.386 13.8904C14.0628 14.0116 13.7025 13.8479 13.5813 13.5247L12.5648 10.8141H7.43487L6.41838 13.5247C6.29718 13.8479 5.93693 14.0116 5.61373 13.8904C5.29052 13.7692 5.12677 13.409 5.24797 13.0858L8.71093 3.85123ZM7.90362 9.56405H12.0961L10.1183 4.29013C10.0998 4.24073 10.0526 4.20801 9.99984 4.20801C9.94709 4.20801 9.89986 4.24073 9.88134 4.29013L7.90362 9.56405Z"
|
||||
/>
|
||||
<rect
|
||||
x="3.3335"
|
||||
y="15"
|
||||
width="13.3333"
|
||||
height="2.08333"
|
||||
rx="1"
|
||||
fill=${this.preprocessColor}
|
||||
/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor color!: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-color-panel': EdgelessColorPanel;
|
||||
'edgeless-color-button': EdgelessColorButton;
|
||||
'edgeless-text-color-icon': EdgelessTextColorIcon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { TextUtils } from '@blocksuite/affine-block-surface';
|
||||
import { CheckIcon } from '@blocksuite/affine-components/icons';
|
||||
import { FontFamily, FontFamilyList } from '@blocksuite/affine-model';
|
||||
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}
|
||||
@click=${() => this._onSelect(font)}
|
||||
>
|
||||
${name} ${active ? CheckIcon : nothing}
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSelect: ((value: FontFamily) => void) | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor value: FontFamily = FontFamily.Inter;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-font-family-panel': EdgelessFontFamilyPanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { TextUtils } from '@blocksuite/affine-block-surface';
|
||||
import { CheckIcon } from '@blocksuite/affine-components/icons';
|
||||
import {
|
||||
FontFamily,
|
||||
FontFamilyMap,
|
||||
FontStyle,
|
||||
FontWeight,
|
||||
} from '@blocksuite/affine-model';
|
||||
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}
|
||||
@click=${() =>
|
||||
this._onSelect(fontFace.weight as FontWeight)}
|
||||
>
|
||||
${choose(fontFace.weight, FONT_WEIGHT_CHOOSE)}
|
||||
${active ? CheckIcon : 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 ? CheckIcon : 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;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-font-weight-and-style-panel': EdgelessFontWeightAndStylePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
BanIcon,
|
||||
DashLineIcon,
|
||||
StraightLineIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { type LineWidth, StrokeStyle } from '@blocksuite/affine-model';
|
||||
import { html } from 'lit';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type { LineWidthEvent } from './line-width-panel.js';
|
||||
|
||||
export type LineStyleEvent =
|
||||
| {
|
||||
type: 'size';
|
||||
value: LineWidth;
|
||||
}
|
||||
| {
|
||||
type: 'lineStyle';
|
||||
value: StrokeStyle;
|
||||
};
|
||||
|
||||
interface LineStylesPanelProps {
|
||||
onClick?: (e: LineStyleEvent) => void;
|
||||
selectedLineSize?: LineWidth;
|
||||
selectedLineStyle?: StrokeStyle;
|
||||
lineStyles?: StrokeStyle[];
|
||||
}
|
||||
|
||||
const LINE_STYLE_LIST = [
|
||||
{
|
||||
name: 'Solid',
|
||||
value: StrokeStyle.Solid,
|
||||
icon: StraightLineIcon,
|
||||
},
|
||||
{
|
||||
name: 'Dash',
|
||||
value: StrokeStyle.Dash,
|
||||
icon: DashLineIcon,
|
||||
},
|
||||
{
|
||||
name: 'None',
|
||||
value: StrokeStyle.None,
|
||||
icon: BanIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export function LineStylesPanel({
|
||||
onClick,
|
||||
selectedLineSize,
|
||||
selectedLineStyle,
|
||||
lineStyles = [StrokeStyle.Solid, StrokeStyle.Dash, StrokeStyle.None],
|
||||
}: LineStylesPanelProps = {}) {
|
||||
const lineSizePanel = html`
|
||||
<edgeless-line-width-panel
|
||||
.selectedSize=${selectedLineSize as LineWidth}
|
||||
.disable=${selectedLineStyle === StrokeStyle.None}
|
||||
@select=${(e: LineWidthEvent) => {
|
||||
onClick?.({
|
||||
type: 'size',
|
||||
value: e.detail,
|
||||
});
|
||||
}}
|
||||
></edgeless-line-width-panel>
|
||||
`;
|
||||
|
||||
const lineStyleButtons = repeat(
|
||||
LINE_STYLE_LIST.filter(item => lineStyles.includes(item.value)),
|
||||
item => item.value,
|
||||
({ name, icon, value }) => {
|
||||
const active = selectedLineStyle === value;
|
||||
const classes: Record<string, boolean> = {
|
||||
'line-style-button': true,
|
||||
[`mode-${value}`]: true,
|
||||
};
|
||||
if (active) classes['active'] = true;
|
||||
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
class=${classMap(classes)}
|
||||
.active=${active}
|
||||
.activeMode=${'background'}
|
||||
.tooltip=${name}
|
||||
@click=${() =>
|
||||
onClick?.({
|
||||
type: 'lineStyle',
|
||||
value,
|
||||
})}
|
||||
>
|
||||
${icon}
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
);
|
||||
|
||||
return html`
|
||||
${lineSizePanel}
|
||||
<editor-toolbar-separator></editor-toolbar-separator>
|
||||
${lineStyleButtons}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { LineWidth } from '@blocksuite/affine-model';
|
||||
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from 'lit';
|
||||
import { property, query, queryAll } from 'lit/decorators.js';
|
||||
|
||||
type DragConfig = {
|
||||
stepWidth: number;
|
||||
boundLeft: number;
|
||||
containerWidth: number;
|
||||
bottomLineWidth: number;
|
||||
};
|
||||
|
||||
export class LineWidthEvent extends Event {
|
||||
detail: LineWidth;
|
||||
|
||||
constructor(
|
||||
type: string,
|
||||
{
|
||||
detail,
|
||||
composed,
|
||||
bubbles,
|
||||
}: { detail: LineWidth; composed: boolean; bubbles: boolean }
|
||||
) {
|
||||
super(type, { bubbles, composed });
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
export class EdgelessLineWidthPanel extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.line-width-panel {
|
||||
width: 108px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.line-width-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.line-width-icon {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--affine-border-color);
|
||||
}
|
||||
|
||||
.line-width-button:nth-child(1) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.line-width-button:nth-child(6) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
border-radius: 50%;
|
||||
background-color: var(--affine-icon-color);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.bottom-line,
|
||||
.line-width-overlay {
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 1px;
|
||||
background-color: var(--affine-border-color);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.bottom-line {
|
||||
width: calc(100% - 16px);
|
||||
background-color: var(--affine-border-color);
|
||||
}
|
||||
|
||||
.line-width-overlay {
|
||||
width: 0;
|
||||
background-color: var(--affine-icon-color);
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
private _dragConfig: DragConfig | null = null;
|
||||
|
||||
private _getDragHandlePosition = (e: PointerEvent, config: DragConfig) => {
|
||||
const x = e.clientX;
|
||||
const { boundLeft, bottomLineWidth, stepWidth, containerWidth } = config;
|
||||
|
||||
let steps: number;
|
||||
if (x <= boundLeft) {
|
||||
steps = 0;
|
||||
} else if (x - boundLeft >= containerWidth) {
|
||||
steps = 100;
|
||||
} else {
|
||||
steps = Math.floor((x - boundLeft) / stepWidth);
|
||||
}
|
||||
|
||||
// The drag handle should not be dragged to the left of the first icon or right of the last icon.
|
||||
// Calculate the drag handle position based on the steps.
|
||||
const bottomLineOffsetX = 4;
|
||||
const bottomLineStepWidth = (bottomLineWidth - bottomLineOffsetX) / 100;
|
||||
const dragHandlerPosition = steps * bottomLineStepWidth;
|
||||
return dragHandlerPosition;
|
||||
};
|
||||
|
||||
private _onPointerDown = (e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
if (this.disable) return;
|
||||
const { left, width } = this._lineWidthPanel.getBoundingClientRect();
|
||||
const bottomLineWidth = this._bottomLine.getBoundingClientRect().width;
|
||||
this._dragConfig = {
|
||||
stepWidth: width / 100,
|
||||
boundLeft: left,
|
||||
containerWidth: width,
|
||||
bottomLineWidth,
|
||||
};
|
||||
this._onPointerMove(e);
|
||||
};
|
||||
|
||||
private _onPointerMove = (e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
if (!this._dragConfig) return;
|
||||
const dragHandlerPosition = this._getDragHandlePosition(
|
||||
e,
|
||||
this._dragConfig
|
||||
);
|
||||
this._dragHandle.style.left = `${dragHandlerPosition}%`;
|
||||
this._lineWidthOverlay.style.width = `${dragHandlerPosition}%`;
|
||||
this._updateIconsColor();
|
||||
};
|
||||
|
||||
private _onPointerOut = (e: PointerEvent) => {
|
||||
// If the pointer is out of the line width panel
|
||||
// Stop dragging and update the selected size by nearest size.
|
||||
e.preventDefault();
|
||||
if (!this._dragConfig) return;
|
||||
const dragHandlerPosition = this._getDragHandlePosition(
|
||||
e,
|
||||
this._dragConfig
|
||||
);
|
||||
this._updateLineWidthPanelByDragHandlePosition(dragHandlerPosition);
|
||||
this._dragConfig = null;
|
||||
};
|
||||
|
||||
private _onPointerUp = (e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
if (!this._dragConfig) return;
|
||||
const dragHandlerPosition = this._getDragHandlePosition(
|
||||
e,
|
||||
this._dragConfig
|
||||
);
|
||||
this._updateLineWidthPanelByDragHandlePosition(dragHandlerPosition);
|
||||
this._dragConfig = null;
|
||||
};
|
||||
|
||||
private _updateIconsColor = () => {
|
||||
if (!this._dragHandle.offsetParent) {
|
||||
requestConnectedFrame(() => this._updateIconsColor(), this);
|
||||
return;
|
||||
}
|
||||
|
||||
const dragHandleRect = this._dragHandle.getBoundingClientRect();
|
||||
const dragHandleCenterX = dragHandleRect.left + dragHandleRect.width / 2;
|
||||
// All the icons located at the left of the drag handle should be filled with the icon color.
|
||||
const leftIcons = [];
|
||||
// All the icons located at the right of the drag handle should be filled with the border color.
|
||||
const rightIcons = [];
|
||||
|
||||
for (const icon of this._lineWidthIcons) {
|
||||
const { left, width } = icon.getBoundingClientRect();
|
||||
const centerX = left + width / 2;
|
||||
if (centerX < dragHandleCenterX) {
|
||||
leftIcons.push(icon);
|
||||
} else {
|
||||
rightIcons.push(icon);
|
||||
}
|
||||
}
|
||||
|
||||
leftIcons.forEach(
|
||||
icon => (icon.style.backgroundColor = 'var(--affine-icon-color)')
|
||||
);
|
||||
rightIcons.forEach(
|
||||
icon => (icon.style.backgroundColor = 'var(--affine-border-color)')
|
||||
);
|
||||
};
|
||||
|
||||
private _onSelect(lineWidth: LineWidth) {
|
||||
// If the selected size is the same as the previous one, do nothing.
|
||||
if (lineWidth === this.selectedSize) return;
|
||||
this.dispatchEvent(
|
||||
new LineWidthEvent('select', {
|
||||
detail: lineWidth,
|
||||
composed: true,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
this.selectedSize = lineWidth;
|
||||
}
|
||||
|
||||
private _updateLineWidthPanel(selectedSize: LineWidth) {
|
||||
if (!this._lineWidthOverlay) return;
|
||||
let width = 0;
|
||||
let dragHandleOffsetX = 0;
|
||||
switch (selectedSize) {
|
||||
case LineWidth.Two:
|
||||
width = 0;
|
||||
break;
|
||||
case LineWidth.Four:
|
||||
width = 16;
|
||||
dragHandleOffsetX = 1;
|
||||
break;
|
||||
case LineWidth.Six:
|
||||
width = 32;
|
||||
dragHandleOffsetX = 2;
|
||||
break;
|
||||
case LineWidth.Eight:
|
||||
width = 48;
|
||||
dragHandleOffsetX = 3;
|
||||
break;
|
||||
case LineWidth.Ten:
|
||||
width = 64;
|
||||
dragHandleOffsetX = 4;
|
||||
break;
|
||||
default:
|
||||
width = 80;
|
||||
dragHandleOffsetX = 4;
|
||||
}
|
||||
|
||||
dragHandleOffsetX += 4;
|
||||
this._lineWidthOverlay.style.width = `${width}%`;
|
||||
this._dragHandle.style.left = `${width}%`;
|
||||
this._dragHandle.style.transform = `translateY(-50%) translateX(${dragHandleOffsetX}px)`;
|
||||
this._updateIconsColor();
|
||||
}
|
||||
|
||||
private _updateLineWidthPanelByDragHandlePosition(
|
||||
dragHandlerPosition: number
|
||||
) {
|
||||
// Calculate the selected size based on the drag handle position.
|
||||
// Need to select the nearest size.
|
||||
let selectedSize = this.selectedSize;
|
||||
if (dragHandlerPosition <= 12) {
|
||||
selectedSize = LineWidth.Two;
|
||||
} else if (dragHandlerPosition > 12 && dragHandlerPosition <= 26) {
|
||||
selectedSize = LineWidth.Four;
|
||||
} else if (dragHandlerPosition > 26 && dragHandlerPosition <= 40) {
|
||||
selectedSize = LineWidth.Six;
|
||||
} else if (dragHandlerPosition > 40 && dragHandlerPosition <= 54) {
|
||||
selectedSize = LineWidth.Eight;
|
||||
} else if (dragHandlerPosition > 54 && dragHandlerPosition <= 68) {
|
||||
selectedSize = LineWidth.Ten;
|
||||
} else {
|
||||
selectedSize = LineWidth.Twelve;
|
||||
}
|
||||
this._updateLineWidthPanel(selectedSize);
|
||||
this._onSelect(selectedSize);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
this._updateLineWidthPanel(this.selectedSize);
|
||||
this._disposables.addFromEvent(this, 'pointerdown', this._onPointerDown);
|
||||
this._disposables.addFromEvent(this, 'pointermove', this._onPointerMove);
|
||||
this._disposables.addFromEvent(this, 'pointerup', this._onPointerUp);
|
||||
this._disposables.addFromEvent(this, 'pointerout', this._onPointerOut);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<style>
|
||||
.line-width-panel {
|
||||
opacity: ${this.disable ? '0.5' : '1'};
|
||||
}
|
||||
</style>
|
||||
<div
|
||||
class="line-width-panel"
|
||||
@mousedown="${(e: Event) => e.preventDefault()}"
|
||||
>
|
||||
<div class="line-width-button">
|
||||
<div class="line-width-icon"></div>
|
||||
</div>
|
||||
<div class="line-width-button">
|
||||
<div class="line-width-icon"></div>
|
||||
</div>
|
||||
<div class="line-width-button">
|
||||
<div class="line-width-icon"></div>
|
||||
</div>
|
||||
<div class="line-width-button">
|
||||
<div class="line-width-icon"></div>
|
||||
</div>
|
||||
<div class="line-width-button">
|
||||
<div class="line-width-icon"></div>
|
||||
</div>
|
||||
<div class="line-width-button">
|
||||
<div class="line-width-icon"></div>
|
||||
</div>
|
||||
<div class="drag-handle"></div>
|
||||
<div class="bottom-line"></div>
|
||||
<div class="line-width-overlay"></div>
|
||||
${this.hasTooltip
|
||||
? html`<affine-tooltip .offset=${8}>Thickness</affine-tooltip>`
|
||||
: nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has('selectedSize')) {
|
||||
this._updateLineWidthPanel(this.selectedSize);
|
||||
}
|
||||
}
|
||||
|
||||
@query('.bottom-line')
|
||||
private accessor _bottomLine!: HTMLElement;
|
||||
|
||||
@query('.drag-handle')
|
||||
private accessor _dragHandle!: HTMLElement;
|
||||
|
||||
@queryAll('.line-width-icon')
|
||||
private accessor _lineWidthIcons!: NodeListOf<HTMLElement>;
|
||||
|
||||
@query('.line-width-overlay')
|
||||
private accessor _lineWidthOverlay!: HTMLElement;
|
||||
|
||||
@query('.line-width-panel')
|
||||
private accessor _lineWidthPanel!: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor disable = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hasTooltip = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor selectedSize: LineWidth = LineWidth.Two;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-line-width-panel': EdgelessLineWidthPanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/affine-components/icons';
|
||||
import { NoteDisplayMode } from '@blocksuite/affine-model';
|
||||
import { stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
export class NoteDisplayModePanel extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 180px;
|
||||
width: var(--panel-width);
|
||||
gap: 4px;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
.item-label {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
.item-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--affine-icon-color);
|
||||
}
|
||||
.item:hover,
|
||||
.item.selected {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
`;
|
||||
|
||||
private _DisplayModeIcon(mode: NoteDisplayMode) {
|
||||
switch (mode) {
|
||||
case NoteDisplayMode.DocAndEdgeless:
|
||||
return html`${PageIcon} ${EdgelessIcon}`;
|
||||
case NoteDisplayMode.DocOnly:
|
||||
return html`${PageIcon}`;
|
||||
case NoteDisplayMode.EdgelessOnly:
|
||||
return html`${EdgelessIcon}`;
|
||||
}
|
||||
}
|
||||
|
||||
private _DisplayModeLabel(mode: NoteDisplayMode) {
|
||||
switch (mode) {
|
||||
case NoteDisplayMode.DocAndEdgeless:
|
||||
return 'In Both';
|
||||
case NoteDisplayMode.DocOnly:
|
||||
return 'In Page Only';
|
||||
case NoteDisplayMode.EdgelessOnly:
|
||||
return 'In Edgeless Only';
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
this.style.setProperty('--panel-width', `${this.panelWidth}px`);
|
||||
|
||||
return repeat(
|
||||
Object.keys(NoteDisplayMode),
|
||||
mode => mode,
|
||||
mode => {
|
||||
const displayMode =
|
||||
NoteDisplayMode[mode as keyof typeof NoteDisplayMode];
|
||||
const isSelected = displayMode === this.displayMode;
|
||||
return html`<div
|
||||
class="item ${isSelected ? 'selected' : ''} ${displayMode}"
|
||||
@click=${() => this.onSelect(displayMode)}
|
||||
@dblclick=${stopPropagation}
|
||||
@pointerdown=${stopPropagation}
|
||||
>
|
||||
<div class="item-label">${this._DisplayModeLabel(displayMode)}</div>
|
||||
<div class="item-icon">${this._DisplayModeIcon(displayMode)}</div>
|
||||
</div>`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor displayMode!: NoteDisplayMode;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSelect!: (displayMode: NoteDisplayMode) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor panelWidth = 240;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'note-display-mode-panel': NoteDisplayModePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
NoteNoShadowIcon,
|
||||
NoteShadowSampleIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { ColorScheme, NoteShadow } from '@blocksuite/affine-model';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
const SHADOWS = [
|
||||
{
|
||||
type: NoteShadow.None,
|
||||
styles: {
|
||||
light: '',
|
||||
dark: '',
|
||||
},
|
||||
tooltip: 'No shadow',
|
||||
},
|
||||
{
|
||||
type: NoteShadow.Box,
|
||||
styles: {
|
||||
light:
|
||||
'0px 0.2px 4.8px 0px rgba(66, 65, 73, 0.2), 0px 0px 1.6px 0px rgba(66, 65, 73, 0.2)',
|
||||
dark: '0px 0.2px 6px 0px rgba(0, 0, 0, 0.44), 0px 0px 2px 0px rgba(0, 0, 0, 0.66)',
|
||||
},
|
||||
tooltip: 'Box shadow',
|
||||
},
|
||||
{
|
||||
type: NoteShadow.Sticker,
|
||||
styles: {
|
||||
light:
|
||||
'0px 9.6px 10.4px -4px rgba(66, 65, 73, 0.07), 0px 10.4px 7.2px -8px rgba(66, 65, 73, 0.22)',
|
||||
dark: '0px 9.6px 10.4px -4px rgba(0, 0, 0, 0.66), 0px 10.4px 7.2px -8px rgba(0, 0, 0, 0.44)',
|
||||
},
|
||||
tooltip: 'Sticker shadow',
|
||||
},
|
||||
{
|
||||
type: NoteShadow.Paper,
|
||||
styles: {
|
||||
light:
|
||||
'0px 0px 0px 4px rgba(255, 255, 255, 1), 0px 1.2px 2.4px 4.8px rgba(66, 65, 73, 0.16)',
|
||||
dark: '0px 1.2px 2.4px 4.8px rgba(0, 0, 0, 0.36), 0px 0px 0px 3.4px rgba(75, 75, 75, 1)',
|
||||
},
|
||||
tooltip: 'Paper shadow',
|
||||
},
|
||||
{
|
||||
type: NoteShadow.Float,
|
||||
styles: {
|
||||
light:
|
||||
'0px 5.2px 12px 0px rgba(66, 65, 73, 0.13), 0px 0px 0.4px 1px rgba(0, 0, 0, 0.06)',
|
||||
dark: '0px 5.2px 12px 0px rgba(0, 0, 0, 0.66), 0px 0px 0.4px 1px rgba(0, 0, 0, 0.44)',
|
||||
},
|
||||
tooltip: 'Floation shadow',
|
||||
},
|
||||
{
|
||||
type: NoteShadow.Film,
|
||||
styles: {
|
||||
light:
|
||||
'0px 0px 0px 1.4px rgba(0, 0, 0, 1), 2.4px 2.4px 0px 1px rgba(0, 0, 0, 1)',
|
||||
dark: '0px 0px 0px 1.4px rgba(178, 178, 178, 1), 2.4px 2.4px 0px 1px rgba(178, 178, 178, 1)',
|
||||
},
|
||||
tooltip: 'Film shadow',
|
||||
},
|
||||
];
|
||||
|
||||
export class EdgelessNoteShadowPanel extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return repeat(
|
||||
SHADOWS,
|
||||
shadow => shadow,
|
||||
(shadow, index) =>
|
||||
html`<style>
|
||||
.item-icon svg rect:first-of-type {
|
||||
fill: ${this.background.startsWith('--')
|
||||
? `var(${this.background})`
|
||||
: this.background};
|
||||
}
|
||||
</style>
|
||||
<div
|
||||
class="item"
|
||||
@click=${() => this.onSelect(shadow.type)}
|
||||
style=${styleMap({
|
||||
border:
|
||||
this.value === shadow.type
|
||||
? '1px solid var(--affine-brand-color)'
|
||||
: 'none',
|
||||
})}
|
||||
>
|
||||
<edgeless-tool-icon-button
|
||||
class="item-icon"
|
||||
.tooltip=${shadow.tooltip}
|
||||
.tipPosition=${'bottom'}
|
||||
.iconContainerPadding=${0}
|
||||
style=${styleMap({
|
||||
boxShadow: `${this.theme === ColorScheme.Dark ? shadow.styles.dark : shadow.styles.light}`,
|
||||
})}
|
||||
>
|
||||
${index === 0 ? NoteNoShadowIcon : NoteShadowSampleIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor background!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSelect!: (value: string) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor theme!: ColorScheme;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor value!: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-note-shadow-panel': EdgelessNoteShadowPanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
import { colorContainerStyles, EdgelessColorPanel } from './color-panel.js';
|
||||
|
||||
export class EdgelessOneRowColorPanel extends EdgelessColorPanel {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
padding: 0 2px;
|
||||
gap: 14px;
|
||||
box-sizing: border-box;
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
}
|
||||
|
||||
${colorContainerStyles}
|
||||
|
||||
.color-container {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.color-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
right: calc(100% + 7px);
|
||||
height: 100%;
|
||||
// FIXME: not working
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-one-row-color-panel': EdgelessOneRowColorPanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { clamp, stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
const MIN_SCALE = 0;
|
||||
const MAX_SCALE = 400;
|
||||
|
||||
const SCALE_LIST = [50, 100, 200] as const;
|
||||
|
||||
function format(scale: number) {
|
||||
return `${scale}%`;
|
||||
}
|
||||
|
||||
export class EdgelessScalePanel extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
width: 68px;
|
||||
}
|
||||
|
||||
edgeless-tool-icon-button {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.scale-input {
|
||||
display: flx;
|
||||
align-self: stretch;
|
||||
border: 0.5px solid var(--affine-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scale-input::placeholder {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
|
||||
.scale-input:focus {
|
||||
outline-color: var(--affine-primary-color);
|
||||
outline-width: 0.5px;
|
||||
}
|
||||
`;
|
||||
|
||||
private _onKeydown = (e: KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
const input = e.target as HTMLInputElement;
|
||||
const scale = parseInt(input.value.trim());
|
||||
// Handle edge case where user enters a non-number
|
||||
if (isNaN(scale)) {
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle edge case when user enters a number that is out of range
|
||||
this._onSelect(clamp(scale, this.minScale, this.maxScale));
|
||||
input.value = '';
|
||||
this._onPopperClose();
|
||||
}
|
||||
};
|
||||
|
||||
private _onPopperClose() {
|
||||
this.onPopperCose?.();
|
||||
}
|
||||
|
||||
private _onSelect(scale: number) {
|
||||
this.onSelect?.(scale / 100);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${repeat(
|
||||
this.scaleList,
|
||||
scale => scale,
|
||||
scale => {
|
||||
const classes = `scale-${scale}`;
|
||||
return html`<edgeless-tool-icon-button
|
||||
class=${classes}
|
||||
.iconContainerPadding=${[4, 8]}
|
||||
.activeMode=${'background'}
|
||||
.active=${this.scale === scale}
|
||||
@click=${() => this._onSelect(scale)}
|
||||
>
|
||||
${format(scale)}
|
||||
</edgeless-tool-icon-button>`;
|
||||
}
|
||||
)}
|
||||
|
||||
<input
|
||||
class="scale-input"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min="0"
|
||||
placeholder=${format(Math.trunc(this.scale))}
|
||||
@keydown=${this._onKeydown}
|
||||
@input=${stopPropagation}
|
||||
@click=${stopPropagation}
|
||||
@pointerdown=${stopPropagation}
|
||||
@cut=${stopPropagation}
|
||||
@copy=${stopPropagation}
|
||||
@paste=${stopPropagation}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor maxScale: number = MAX_SCALE;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor minScale: number = MIN_SCALE;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onPopperCose: (() => void) | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSelect: ((size: number) => void) | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor scale!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor scaleList: readonly number[] = SCALE_LIST;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-scale-panel': EdgelessScalePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ShapeStyle } from '@blocksuite/affine-model';
|
||||
import { Slot } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type { ShapeTool } from '../../gfx-tool/shape-tool.js';
|
||||
import { ShapeComponentConfig } from '../toolbar/shape/shape-menu-config.js';
|
||||
|
||||
export class EdgelessShapePanel extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
slots = {
|
||||
select: new Slot<ShapeTool['activatedOption']['shapeName']>(),
|
||||
};
|
||||
|
||||
private _onSelect(value: ShapeTool['activatedOption']['shapeName']) {
|
||||
this.selectedShape = value;
|
||||
this.slots.select.emit(value);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.slots.select.dispose();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return repeat(
|
||||
ShapeComponentConfig,
|
||||
item => item.name,
|
||||
({ name, generalIcon, scribbledIcon, tooltip, disabled }) =>
|
||||
html`<edgeless-tool-icon-button
|
||||
.disabled=${disabled}
|
||||
.tooltip=${tooltip}
|
||||
.active=${this.selectedShape === name}
|
||||
.activeMode=${'background'}
|
||||
@click=${() => {
|
||||
if (disabled) return;
|
||||
this._onSelect(name);
|
||||
}}
|
||||
>
|
||||
${this.shapeStyle === ShapeStyle.General
|
||||
? generalIcon
|
||||
: scribbledIcon}
|
||||
</edgeless-tool-icon-button>`
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor selectedShape:
|
||||
| ShapeTool['activatedOption']['shapeName']
|
||||
| null
|
||||
| undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor shapeStyle: ShapeStyle = ShapeStyle.Scribbled;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-shape-panel': EdgelessShapePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
GeneralStyleIcon,
|
||||
ScribbledStyleIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { ShapeStyle } from '@blocksuite/affine-model';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
const SHAPE_STYLE_LIST = [
|
||||
{
|
||||
value: ShapeStyle.General,
|
||||
icon: GeneralStyleIcon,
|
||||
},
|
||||
{
|
||||
value: ShapeStyle.Scribbled,
|
||||
icon: ScribbledStyleIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export class EdgelessShapeStylePanel extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
private _onSelect(value: ShapeStyle) {
|
||||
this.value = value;
|
||||
if (this.onSelect) {
|
||||
this.onSelect(value);
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return repeat(
|
||||
SHAPE_STYLE_LIST,
|
||||
item => item.value,
|
||||
({ value, icon }) =>
|
||||
html`<edgeless-tool-icon-button
|
||||
.tipPosition=${'top'}
|
||||
.activeMode=${'background'}
|
||||
aria-label=${value}
|
||||
.tooltip=${value}
|
||||
.active=${this.value === value}
|
||||
@click=${() => this._onSelect(value)}
|
||||
>
|
||||
${icon}
|
||||
</edgeless-tool-icon-button>`
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSelect: undefined | ((value: ShapeStyle) => void) = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor value!: ShapeStyle;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-shape-style-panel': EdgelessShapeStylePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { CheckIcon } from '@blocksuite/affine-components/icons';
|
||||
import { clamp, stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
const MIN_SIZE = 1;
|
||||
const MAX_SIZE = 200;
|
||||
|
||||
type SizeItem = {
|
||||
name?: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export class EdgelessSizePanel extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
width: 68px;
|
||||
}
|
||||
|
||||
edgeless-tool-icon-button {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.size-input {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
border: 0.5px solid var(--affine-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.size-input::placeholder {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
|
||||
.size-input:focus {
|
||||
outline-color: var(--affine-primary-color);
|
||||
outline-width: 0.5px;
|
||||
}
|
||||
|
||||
:host([data-type='check']) {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
:host([data-type='check']) .size-input {
|
||||
margin-top: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
private _onKeydown = (e: KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
const input = e.target as HTMLInputElement;
|
||||
const size = parseInt(input.value.trim());
|
||||
// Handle edge case where user enters a non-number
|
||||
if (isNaN(size)) {
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle edge case when user enters a number that is out of range
|
||||
this._onSelect(clamp(size, this.minSize, this.maxSize));
|
||||
input.value = '';
|
||||
this._onPopperClose();
|
||||
}
|
||||
};
|
||||
|
||||
renderItemWithCheck = ({ name, value }: SizeItem) => {
|
||||
const active = this.size === value;
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
.iconContainerPadding=${[4, 8]}
|
||||
.justify=${'space-between'}
|
||||
.active=${active}
|
||||
@click=${() => this._onSelect(value)}
|
||||
>
|
||||
${name ?? value} ${active ? CheckIcon : nothing}
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
};
|
||||
|
||||
renderItemWithNormal = ({ name, value }: SizeItem) => {
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
.iconContainerPadding=${[4, 8]}
|
||||
.active=${this.size === value}
|
||||
.activeMode=${'background'}
|
||||
@click=${() => this._onSelect(value)}
|
||||
>
|
||||
${name ?? value}
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
};
|
||||
|
||||
private _onPopperClose() {
|
||||
this.onPopperCose?.();
|
||||
}
|
||||
|
||||
private _onSelect(size: number) {
|
||||
this.onSelect?.(size);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${repeat(this.sizeList, sizeItem => sizeItem.name, this.renderItem())}
|
||||
|
||||
<input
|
||||
class="size-input"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min="0"
|
||||
placeholder=${Math.trunc(this.size)}
|
||||
@keydown=${this._onKeydown}
|
||||
@input=${stopPropagation}
|
||||
@click=${stopPropagation}
|
||||
@pointerdown=${stopPropagation}
|
||||
@cut=${stopPropagation}
|
||||
@copy=${stopPropagation}
|
||||
@paste=${stopPropagation}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
renderItem() {
|
||||
return this.type === 'normal'
|
||||
? this.renderItemWithNormal
|
||||
: this.renderItemWithCheck;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor maxSize: number = MAX_SIZE;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor minSize: number = MIN_SIZE;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onPopperCose: (() => void) | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSelect: ((size: number) => void) | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor size!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor sizeList!: SizeItem[];
|
||||
|
||||
@property({ attribute: 'data-type' })
|
||||
accessor type: 'normal' | 'check' = 'normal';
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-size-panel': EdgelessSizePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { SHAPE_STROKE_COLORS, StrokeStyle } from '@blocksuite/affine-model';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { ColorEvent } from './color-panel.js';
|
||||
import { type LineStyleEvent, LineStylesPanel } from './line-styles-panel.js';
|
||||
|
||||
export class StrokeStylePanel extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.line-styles {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="line-styles">
|
||||
${LineStylesPanel({
|
||||
selectedLineSize: this.strokeWidth,
|
||||
selectedLineStyle: this.strokeStyle,
|
||||
onClick: e => this.setStrokeStyle(e),
|
||||
lineStyles: [StrokeStyle.Solid, StrokeStyle.Dash],
|
||||
})}
|
||||
</div>
|
||||
<editor-toolbar-separator
|
||||
data-orientation="horizontal"
|
||||
></editor-toolbar-separator>
|
||||
<edgeless-color-panel
|
||||
role="listbox"
|
||||
aria-label="Border colors"
|
||||
.options=${SHAPE_STROKE_COLORS}
|
||||
.value=${this.strokeColor}
|
||||
.hollowCircle=${this.hollowCircle}
|
||||
@select=${(e: ColorEvent) => this.setStrokeColor(e)}
|
||||
>
|
||||
</edgeless-color-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hollowCircle: boolean | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor setStrokeColor!: (e: ColorEvent) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor setStrokeStyle!: (e: LineStyleEvent) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor strokeColor!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor strokeStyle!: StrokeStyle;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor strokeWidth!: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'stroke-style-panel': StrokeStylePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { FrameBlockModel, RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import { WidgetComponent } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import { Bound } from '@blocksuite/global/utils';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
|
||||
export const EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET =
|
||||
'edgeless-navigator-black-background';
|
||||
export class EdgelessNavigatorBlackBackgroundWidget extends WidgetComponent<
|
||||
RootBlockModel,
|
||||
EdgelessRootBlockComponent
|
||||
> {
|
||||
static override styles = css`
|
||||
.edgeless-navigator-black-background {
|
||||
background-color: black;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background-color: transparent;
|
||||
box-shadow: 0 0 0 5000px black;
|
||||
}
|
||||
`;
|
||||
|
||||
private _blackBackground = false;
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
private _tryLoadBlackBackground() {
|
||||
const value = this.std
|
||||
.get(EditPropsStore)
|
||||
.getStorage('presentBlackBackground');
|
||||
this._blackBackground = value ?? true;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { _disposables, gfx, block } = this;
|
||||
_disposables.add(
|
||||
block.slots.navigatorFrameChanged.on(frame => {
|
||||
this.frame = frame;
|
||||
})
|
||||
);
|
||||
|
||||
_disposables.add(
|
||||
block.slots.navigatorSettingUpdated.on(({ blackBackground }) => {
|
||||
if (blackBackground !== undefined) {
|
||||
this.std
|
||||
.get(EditPropsStore)
|
||||
.setStorage('presentBlackBackground', blackBackground);
|
||||
|
||||
this._blackBackground = blackBackground;
|
||||
|
||||
this.show =
|
||||
blackBackground &&
|
||||
block.gfx.tool.currentToolOption$.peek().type === 'frameNavigator';
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
_disposables.add(
|
||||
effect(() => {
|
||||
const tool = gfx.tool.currentToolName$.value;
|
||||
|
||||
if (tool !== 'frameNavigator') {
|
||||
this.show = false;
|
||||
} else {
|
||||
this.show = this._blackBackground;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
_disposables.add(
|
||||
block.slots.fullScreenToggled.on(
|
||||
() =>
|
||||
setTimeout(() => {
|
||||
this.requestUpdate();
|
||||
}, 500) // wait for full screen animation
|
||||
)
|
||||
);
|
||||
|
||||
this._tryLoadBlackBackground();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { frame, show, gfx } = this;
|
||||
|
||||
if (!show || !frame) return nothing;
|
||||
|
||||
const bound = Bound.deserialize(frame.xywh);
|
||||
const zoom = gfx.viewport.zoom;
|
||||
const width = bound.w * zoom;
|
||||
const height = bound.h * zoom;
|
||||
const [x, y] = gfx.viewport.toViewCoord(bound.x, bound.y);
|
||||
|
||||
return html` <style>
|
||||
.edgeless-navigator-black-background {
|
||||
width: ${width}px;
|
||||
height: ${height}px;
|
||||
top: ${y}px;
|
||||
left: ${x}px;
|
||||
}
|
||||
</style>
|
||||
<div class="edgeless-navigator-black-background"></div>`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor frame: FrameBlockModel | undefined = undefined;
|
||||
|
||||
@state()
|
||||
private accessor show = false;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-navigator-black-background': EdgelessNavigatorBlackBackgroundWidget;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { WidgetComponent } from '@blocksuite/block-std';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { css, html, nothing, unsafeCSS } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
import { DefaultTool } from '../../gfx-tool/default-tool.js';
|
||||
import { DefaultModeDragType } from '../../gfx-tool/default-tool-ext/ext.js';
|
||||
|
||||
export const EDGELESS_DRAGGING_AREA_WIDGET = 'edgeless-dragging-area-rect';
|
||||
|
||||
export class EdgelessDraggingAreaRectWidget extends WidgetComponent<
|
||||
RootBlockModel,
|
||||
EdgelessRootBlockComponent
|
||||
> {
|
||||
static override styles = css`
|
||||
.affine-edgeless-dragging-area {
|
||||
position: absolute;
|
||||
background: ${unsafeCSS(
|
||||
cssVarV2('edgeless/selection/selectionMarqueeBackground', '#1E96EB14')
|
||||
)};
|
||||
box-sizing: border-box;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: ${unsafeCSS(
|
||||
cssVarV2('edgeless/selection/selectionMarqueeBorder', '#1E96EB')
|
||||
)};
|
||||
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
const rect = this.block.gfx.tool.draggingViewArea$.value;
|
||||
const tool = this.block.gfx.tool.currentTool$.value;
|
||||
|
||||
if (
|
||||
rect.w === 0 ||
|
||||
rect.h === 0 ||
|
||||
!(tool instanceof DefaultTool) ||
|
||||
tool.dragType !== DefaultModeDragType.Selecting
|
||||
)
|
||||
return nothing;
|
||||
|
||||
const style = {
|
||||
left: rect.x + 'px',
|
||||
top: rect.y + 'px',
|
||||
width: rect.w + 'px',
|
||||
height: rect.h + 'px',
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="affine-edgeless-dragging-area" style=${styleMap(style)}></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-dragging-area-rect': EdgelessDraggingAreaRectWidget;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
import type { IVec } from '@blocksuite/global/utils';
|
||||
import { html, nothing } from 'lit';
|
||||
|
||||
export enum HandleDirection {
|
||||
Bottom = 'bottom',
|
||||
BottomLeft = 'bottom-left',
|
||||
BottomRight = 'bottom-right',
|
||||
Left = 'left',
|
||||
Right = 'right',
|
||||
Top = 'top',
|
||||
TopLeft = 'top-left',
|
||||
TopRight = 'top-right',
|
||||
}
|
||||
|
||||
function ResizeHandle(
|
||||
handleDirection: HandleDirection,
|
||||
onPointerDown?: (e: PointerEvent, direction: HandleDirection) => void,
|
||||
updateCursor?: (
|
||||
dragging: boolean,
|
||||
options?: {
|
||||
type: 'resize' | 'rotate';
|
||||
target?: HTMLElement;
|
||||
point?: IVec;
|
||||
}
|
||||
) => void,
|
||||
hideEdgeHandle?: boolean
|
||||
) {
|
||||
const handlerPointerDown = (e: PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
onPointerDown && onPointerDown(e, handleDirection);
|
||||
};
|
||||
|
||||
const pointerEnter = (type: 'resize' | 'rotate') => (e: PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.buttons === 1 || !updateCursor) return;
|
||||
|
||||
const { clientX, clientY } = e;
|
||||
const target = e.target as HTMLElement;
|
||||
const point: IVec = [clientX, clientY];
|
||||
|
||||
updateCursor(true, { type, point, target });
|
||||
};
|
||||
|
||||
const pointerLeave = (e: PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.buttons === 1 || !updateCursor) return;
|
||||
|
||||
updateCursor(false);
|
||||
};
|
||||
|
||||
const rotationTpl =
|
||||
handleDirection === HandleDirection.Top ||
|
||||
handleDirection === HandleDirection.Bottom ||
|
||||
handleDirection === HandleDirection.Left ||
|
||||
handleDirection === HandleDirection.Right
|
||||
? nothing
|
||||
: html`<div
|
||||
class="rotate"
|
||||
@pointerover=${pointerEnter('rotate')}
|
||||
@pointerout=${pointerLeave}
|
||||
></div>`;
|
||||
|
||||
return html`<div
|
||||
class="handle"
|
||||
aria-label=${handleDirection}
|
||||
@pointerdown=${handlerPointerDown}
|
||||
>
|
||||
${rotationTpl}
|
||||
<div
|
||||
class="resize${hideEdgeHandle && ' transparent-handle'}"
|
||||
@pointerover=${pointerEnter('resize')}
|
||||
@pointerout=${pointerLeave}
|
||||
></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate how selected elements can be resized.
|
||||
*
|
||||
* - edge: The selected elements can only be resized dragging edge, usually when note element is selected
|
||||
* - all: The selected elements can be resize both dragging edge or corner, usually when all elements are `shape`
|
||||
* - none: The selected elements can't be resized, usually when all elements are `connector`
|
||||
* - corner: The selected elements can only be resize dragging corner, this is by default mode
|
||||
* - edgeAndCorner: The selected elements can be resize both dragging left right edge or corner, usually when all elements are 'text'
|
||||
*/
|
||||
export type ResizeMode = 'edge' | 'all' | 'none' | 'corner' | 'edgeAndCorner';
|
||||
|
||||
export function ResizeHandles(
|
||||
resizeMode: ResizeMode,
|
||||
onPointerDown: (e: PointerEvent, direction: HandleDirection) => void,
|
||||
updateCursor?: (
|
||||
dragging: boolean,
|
||||
options?: {
|
||||
type: 'resize' | 'rotate';
|
||||
target?: HTMLElement;
|
||||
point?: IVec;
|
||||
}
|
||||
) => void
|
||||
) {
|
||||
const getCornerHandles = () => {
|
||||
const handleTopLeft = ResizeHandle(
|
||||
HandleDirection.TopLeft,
|
||||
onPointerDown,
|
||||
updateCursor
|
||||
);
|
||||
const handleTopRight = ResizeHandle(
|
||||
HandleDirection.TopRight,
|
||||
onPointerDown,
|
||||
updateCursor
|
||||
);
|
||||
const handleBottomLeft = ResizeHandle(
|
||||
HandleDirection.BottomLeft,
|
||||
onPointerDown,
|
||||
updateCursor
|
||||
);
|
||||
const handleBottomRight = ResizeHandle(
|
||||
HandleDirection.BottomRight,
|
||||
onPointerDown,
|
||||
updateCursor
|
||||
);
|
||||
return {
|
||||
handleTopLeft,
|
||||
handleTopRight,
|
||||
handleBottomLeft,
|
||||
handleBottomRight,
|
||||
};
|
||||
};
|
||||
const getEdgeHandles = (hideEdgeHandle?: boolean) => {
|
||||
const handleLeft = ResizeHandle(
|
||||
HandleDirection.Left,
|
||||
onPointerDown,
|
||||
updateCursor,
|
||||
hideEdgeHandle
|
||||
);
|
||||
const handleRight = ResizeHandle(
|
||||
HandleDirection.Right,
|
||||
onPointerDown,
|
||||
updateCursor,
|
||||
hideEdgeHandle
|
||||
);
|
||||
return { handleLeft, handleRight };
|
||||
};
|
||||
const getEdgeVerticalHandles = (hideEdgeHandle?: boolean) => {
|
||||
const handleTop = ResizeHandle(
|
||||
HandleDirection.Top,
|
||||
onPointerDown,
|
||||
updateCursor,
|
||||
hideEdgeHandle
|
||||
);
|
||||
const handleBottom = ResizeHandle(
|
||||
HandleDirection.Bottom,
|
||||
onPointerDown,
|
||||
updateCursor,
|
||||
hideEdgeHandle
|
||||
);
|
||||
return { handleTop, handleBottom };
|
||||
};
|
||||
switch (resizeMode) {
|
||||
case 'corner': {
|
||||
const {
|
||||
handleTopLeft,
|
||||
handleTopRight,
|
||||
handleBottomLeft,
|
||||
handleBottomRight,
|
||||
} = getCornerHandles();
|
||||
|
||||
// prettier-ignore
|
||||
return html`
|
||||
${handleTopLeft}
|
||||
${handleTopRight}
|
||||
${handleBottomLeft}
|
||||
${handleBottomRight}
|
||||
`;
|
||||
}
|
||||
case 'edge': {
|
||||
const { handleLeft, handleRight } = getEdgeHandles();
|
||||
return html`${handleLeft} ${handleRight}`;
|
||||
}
|
||||
case 'all': {
|
||||
const {
|
||||
handleTopLeft,
|
||||
handleTopRight,
|
||||
handleBottomLeft,
|
||||
handleBottomRight,
|
||||
} = getCornerHandles();
|
||||
const { handleLeft, handleRight } = getEdgeHandles(true);
|
||||
const { handleTop, handleBottom } = getEdgeVerticalHandles(true);
|
||||
|
||||
// prettier-ignore
|
||||
return html`
|
||||
${handleTopLeft}
|
||||
${handleTop}
|
||||
${handleTopRight}
|
||||
${handleRight}
|
||||
${handleBottomRight}
|
||||
${handleBottom}
|
||||
${handleBottomLeft}
|
||||
${handleLeft}
|
||||
`;
|
||||
}
|
||||
case 'edgeAndCorner': {
|
||||
const {
|
||||
handleTopLeft,
|
||||
handleTopRight,
|
||||
handleBottomLeft,
|
||||
handleBottomRight,
|
||||
} = getCornerHandles();
|
||||
const { handleLeft, handleRight } = getEdgeHandles(true);
|
||||
|
||||
return html`
|
||||
${handleTopLeft} ${handleTopRight} ${handleRight} ${handleBottomRight}
|
||||
${handleBottomLeft} ${handleLeft}
|
||||
`;
|
||||
}
|
||||
case 'none': {
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
import { CommonUtils } from '@blocksuite/affine-block-surface';
|
||||
import { NOTE_MIN_WIDTH } from '@blocksuite/affine-model';
|
||||
import {
|
||||
assertExists,
|
||||
Bound,
|
||||
getQuadBoundWithRotation,
|
||||
type IPoint,
|
||||
type IVec,
|
||||
type PointLocation,
|
||||
} from '@blocksuite/global/utils';
|
||||
|
||||
import type { SelectableProps } from '../../utils/query.js';
|
||||
import { HandleDirection, type ResizeMode } from './resize-handles.js';
|
||||
|
||||
const { rotatePoints } = CommonUtils;
|
||||
|
||||
// 15deg
|
||||
const SHIFT_LOCKING_ANGLE = Math.PI / 12;
|
||||
|
||||
type DragStartHandler = () => void;
|
||||
type DragEndHandler = () => void;
|
||||
|
||||
type ResizeMoveHandler = (
|
||||
bounds: Map<
|
||||
string,
|
||||
{
|
||||
bound: Bound;
|
||||
path?: PointLocation[];
|
||||
matrix?: DOMMatrix;
|
||||
}
|
||||
>,
|
||||
direction: HandleDirection
|
||||
) => void;
|
||||
|
||||
type RotateMoveHandler = (point: IPoint, rotate: number) => void;
|
||||
|
||||
export class HandleResizeManager {
|
||||
private _aspectRatio = 1;
|
||||
|
||||
private _bounds = new Map<
|
||||
string,
|
||||
{
|
||||
bound: Bound;
|
||||
rotate: number;
|
||||
}
|
||||
>();
|
||||
|
||||
/**
|
||||
* Current rect of selected elements, it may change during resizing or moving
|
||||
*/
|
||||
private _currentRect = new DOMRect();
|
||||
|
||||
private _dragDirection: HandleDirection = HandleDirection.Left;
|
||||
|
||||
private _dragging = false;
|
||||
|
||||
private _dragPos: {
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
} = {
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
private _locked = false;
|
||||
|
||||
private _onDragEnd: DragEndHandler;
|
||||
|
||||
private _onDragStart: DragStartHandler;
|
||||
|
||||
private _onResizeMove: ResizeMoveHandler;
|
||||
|
||||
private _onRotateMove: RotateMoveHandler;
|
||||
|
||||
private _origin: { x: number; y: number } = { x: 0, y: 0 };
|
||||
|
||||
/**
|
||||
* Record inital rect of selected elements
|
||||
*/
|
||||
private _originalRect = new DOMRect();
|
||||
|
||||
private _proportion = false;
|
||||
|
||||
private _proportional = false;
|
||||
|
||||
private _resizeMode: ResizeMode = 'none';
|
||||
|
||||
private _rotate = 0;
|
||||
|
||||
private _rotation = false;
|
||||
|
||||
private _shiftKey = false;
|
||||
|
||||
private _target: HTMLElement | null = null;
|
||||
|
||||
private _zoom = 1;
|
||||
|
||||
onPointerDown = (
|
||||
e: PointerEvent,
|
||||
direction: HandleDirection,
|
||||
proportional = false
|
||||
) => {
|
||||
// Prevent selection action from being triggered
|
||||
e.stopPropagation();
|
||||
|
||||
this._locked = false;
|
||||
this._target = e.target as HTMLElement;
|
||||
this._dragDirection = direction;
|
||||
this._dragPos.start = { x: e.x, y: e.y };
|
||||
this._dragPos.end = { x: e.x, y: e.y };
|
||||
this._rotation = this._target.classList.contains('rotate');
|
||||
this._proportional = proportional;
|
||||
|
||||
if (this._rotation) {
|
||||
const rect = this._target
|
||||
.closest('.affine-edgeless-selected-rect')
|
||||
?.getBoundingClientRect();
|
||||
assertExists(rect);
|
||||
const { left, top, right, bottom } = rect;
|
||||
const x = (left + right) / 2;
|
||||
const y = (top + bottom) / 2;
|
||||
// center of `selected-rect` in viewport
|
||||
this._origin = { x, y };
|
||||
}
|
||||
|
||||
this._dragging = true;
|
||||
this._onDragStart();
|
||||
|
||||
const _onPointerMove = ({ x, y, shiftKey }: PointerEvent) => {
|
||||
if (this._resizeMode === 'none') return;
|
||||
|
||||
this._shiftKey = shiftKey;
|
||||
this._dragPos.end = { x, y };
|
||||
|
||||
const proportional = this._proportional || this._shiftKey;
|
||||
|
||||
if (this._rotation) {
|
||||
this._onRotate(proportional);
|
||||
return;
|
||||
}
|
||||
|
||||
this._onResize(proportional);
|
||||
};
|
||||
|
||||
const _onPointerUp = (_: PointerEvent) => {
|
||||
this._dragging = false;
|
||||
this._onDragEnd();
|
||||
|
||||
const { x, y, width, height } = this._currentRect;
|
||||
this._originalRect = new DOMRect(x, y, width, height);
|
||||
|
||||
this._locked = true;
|
||||
this._shiftKey = false;
|
||||
this._rotation = false;
|
||||
this._dragPos = {
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
document.removeEventListener('pointermove', _onPointerMove);
|
||||
document.removeEventListener('pointerup', _onPointerUp);
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', _onPointerMove);
|
||||
document.addEventListener('pointerup', _onPointerUp);
|
||||
};
|
||||
|
||||
get bounds() {
|
||||
return this._bounds;
|
||||
}
|
||||
|
||||
get currentRect() {
|
||||
return this._currentRect;
|
||||
}
|
||||
|
||||
get dragDirection() {
|
||||
return this._dragDirection;
|
||||
}
|
||||
|
||||
get dragging() {
|
||||
return this._dragging;
|
||||
}
|
||||
|
||||
get originalRect() {
|
||||
return this._originalRect;
|
||||
}
|
||||
|
||||
get rotation() {
|
||||
return this._rotation;
|
||||
}
|
||||
|
||||
constructor(
|
||||
onDragStart: DragStartHandler,
|
||||
onResizeMove: ResizeMoveHandler,
|
||||
onRotateMove: RotateMoveHandler,
|
||||
onDragEnd: DragEndHandler
|
||||
) {
|
||||
this._onDragStart = onDragStart;
|
||||
this._onResizeMove = onResizeMove;
|
||||
this._onRotateMove = onRotateMove;
|
||||
this._onDragEnd = onDragEnd;
|
||||
}
|
||||
|
||||
private _onResize(proportion: boolean) {
|
||||
const {
|
||||
_aspectRatio,
|
||||
_dragDirection,
|
||||
_dragPos,
|
||||
_rotate,
|
||||
_resizeMode,
|
||||
_zoom,
|
||||
_target,
|
||||
_originalRect,
|
||||
_currentRect,
|
||||
} = this;
|
||||
proportion ||= this._proportion;
|
||||
assertExists(_target);
|
||||
|
||||
const isAll = _resizeMode === 'all';
|
||||
const isCorner = _resizeMode === 'corner';
|
||||
const isEdgeAndCorner = _resizeMode === 'edgeAndCorner';
|
||||
|
||||
const {
|
||||
start: { x: startX, y: startY },
|
||||
end: { x: endX, y: endY },
|
||||
} = _dragPos;
|
||||
|
||||
const { left: minX, top: minY, right: maxX, bottom: maxY } = _originalRect;
|
||||
const original = {
|
||||
w: maxX - minX,
|
||||
h: maxY - minY,
|
||||
cx: (minX + maxX) / 2,
|
||||
cy: (minY + maxY) / 2,
|
||||
};
|
||||
const rect = { ...original };
|
||||
const scale = { x: 1, y: 1 };
|
||||
const flip = { x: 1, y: 1 };
|
||||
const direction = { x: 1, y: 1 };
|
||||
const fixedPoint = new DOMPoint(0, 0);
|
||||
const draggingPoint = new DOMPoint(0, 0);
|
||||
|
||||
const deltaX = (endX - startX) / _zoom;
|
||||
const deltaY = (endY - startY) / _zoom;
|
||||
|
||||
const m0 = new DOMMatrix()
|
||||
.translateSelf(original.cx, original.cy)
|
||||
.rotateSelf(_rotate)
|
||||
.translateSelf(-original.cx, -original.cy);
|
||||
|
||||
if (isCorner || isAll || isEdgeAndCorner) {
|
||||
switch (_dragDirection) {
|
||||
case HandleDirection.TopLeft: {
|
||||
direction.x = -1;
|
||||
direction.y = -1;
|
||||
fixedPoint.x = maxX;
|
||||
fixedPoint.y = maxY;
|
||||
draggingPoint.x = minX;
|
||||
draggingPoint.y = minY;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.TopRight: {
|
||||
direction.x = 1;
|
||||
direction.y = -1;
|
||||
fixedPoint.x = minX;
|
||||
fixedPoint.y = maxY;
|
||||
draggingPoint.x = maxX;
|
||||
draggingPoint.y = minY;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.BottomRight: {
|
||||
direction.x = 1;
|
||||
direction.y = 1;
|
||||
fixedPoint.x = minX;
|
||||
fixedPoint.y = minY;
|
||||
draggingPoint.x = maxX;
|
||||
draggingPoint.y = maxY;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.BottomLeft: {
|
||||
direction.x = -1;
|
||||
direction.y = 1;
|
||||
fixedPoint.x = maxX;
|
||||
fixedPoint.y = minY;
|
||||
draggingPoint.x = minX;
|
||||
draggingPoint.y = maxY;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Left: {
|
||||
direction.x = -1;
|
||||
direction.y = 1;
|
||||
fixedPoint.x = maxX;
|
||||
fixedPoint.y = original.cy;
|
||||
draggingPoint.x = minX;
|
||||
draggingPoint.y = original.cy;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Right: {
|
||||
direction.x = 1;
|
||||
direction.y = 1;
|
||||
fixedPoint.x = minX;
|
||||
fixedPoint.y = original.cy;
|
||||
draggingPoint.x = maxX;
|
||||
draggingPoint.y = original.cy;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Top: {
|
||||
const cx = (minX + maxX) / 2;
|
||||
direction.x = 1;
|
||||
direction.y = -1;
|
||||
fixedPoint.x = cx;
|
||||
fixedPoint.y = maxY;
|
||||
draggingPoint.x = cx;
|
||||
draggingPoint.y = minY;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Bottom: {
|
||||
const cx = (minX + maxX) / 2;
|
||||
direction.x = 1;
|
||||
direction.y = 1;
|
||||
fixedPoint.x = cx;
|
||||
fixedPoint.y = minY;
|
||||
draggingPoint.x = cx;
|
||||
draggingPoint.y = maxY;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// force adjustment by aspect ratio
|
||||
proportion ||= this._bounds.size > 1;
|
||||
|
||||
const fp = fixedPoint.matrixTransform(m0);
|
||||
let dp = draggingPoint.matrixTransform(m0);
|
||||
|
||||
dp.x += deltaX;
|
||||
dp.y += deltaY;
|
||||
|
||||
if (
|
||||
_dragDirection === HandleDirection.Left ||
|
||||
_dragDirection === HandleDirection.Right ||
|
||||
_dragDirection === HandleDirection.Top ||
|
||||
_dragDirection === HandleDirection.Bottom
|
||||
) {
|
||||
const dpo = draggingPoint.matrixTransform(m0);
|
||||
const coorPoint: IVec = [0, 0];
|
||||
const [[x1, y1]] = rotatePoints([[dpo.x, dpo.y]], coorPoint, -_rotate);
|
||||
const [[x2, y2]] = rotatePoints([[dp.x, dp.y]], coorPoint, -_rotate);
|
||||
const point = { x: 0, y: 0 };
|
||||
if (
|
||||
_dragDirection === HandleDirection.Left ||
|
||||
_dragDirection === HandleDirection.Right
|
||||
) {
|
||||
point.x = x2;
|
||||
point.y = y1;
|
||||
} else {
|
||||
point.x = x1;
|
||||
point.y = y2;
|
||||
}
|
||||
|
||||
const [[x3, y3]] = rotatePoints(
|
||||
[[point.x, point.y]],
|
||||
coorPoint,
|
||||
_rotate
|
||||
);
|
||||
|
||||
dp.x = x3;
|
||||
dp.y = y3;
|
||||
}
|
||||
|
||||
const cx = (fp.x + dp.x) / 2;
|
||||
const cy = (fp.y + dp.y) / 2;
|
||||
|
||||
const m1 = new DOMMatrix()
|
||||
.translateSelf(cx, cy)
|
||||
.rotateSelf(-_rotate)
|
||||
.translateSelf(-cx, -cy);
|
||||
|
||||
const f = fp.matrixTransform(m1);
|
||||
const d = dp.matrixTransform(m1);
|
||||
|
||||
switch (_dragDirection) {
|
||||
case HandleDirection.TopLeft: {
|
||||
rect.w = f.x - d.x;
|
||||
rect.h = f.y - d.y;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.TopRight: {
|
||||
rect.w = d.x - f.x;
|
||||
rect.h = f.y - d.y;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.BottomRight: {
|
||||
rect.w = d.x - f.x;
|
||||
rect.h = d.y - f.y;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.BottomLeft: {
|
||||
rect.w = f.x - d.x;
|
||||
rect.h = d.y - f.y;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Left: {
|
||||
rect.w = f.x - d.x;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Right: {
|
||||
rect.w = d.x - f.x;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Top: {
|
||||
rect.h = f.y - d.y;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Bottom: {
|
||||
rect.h = d.y - f.y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
rect.cx = (d.x + f.x) / 2;
|
||||
rect.cy = (d.y + f.y) / 2;
|
||||
scale.x = rect.w / original.w;
|
||||
scale.y = rect.h / original.h;
|
||||
flip.x = scale.x < 0 ? -1 : 1;
|
||||
flip.y = scale.y < 0 ? -1 : 1;
|
||||
|
||||
const isDraggingCorner =
|
||||
_dragDirection === HandleDirection.TopLeft ||
|
||||
_dragDirection === HandleDirection.TopRight ||
|
||||
_dragDirection === HandleDirection.BottomRight ||
|
||||
_dragDirection === HandleDirection.BottomLeft;
|
||||
|
||||
// lock aspect ratio
|
||||
if (proportion && isDraggingCorner) {
|
||||
const newAspectRatio = Math.abs(rect.w / rect.h);
|
||||
if (_aspectRatio < newAspectRatio) {
|
||||
scale.y = Math.abs(scale.x) * flip.y;
|
||||
rect.h = scale.y * original.h;
|
||||
} else {
|
||||
scale.x = Math.abs(scale.y) * flip.x;
|
||||
rect.w = scale.x * original.w;
|
||||
}
|
||||
draggingPoint.x = fixedPoint.x + rect.w * direction.x;
|
||||
draggingPoint.y = fixedPoint.y + rect.h * direction.y;
|
||||
|
||||
dp = draggingPoint.matrixTransform(m0);
|
||||
|
||||
rect.cx = (fp.x + dp.x) / 2;
|
||||
rect.cy = (fp.y + dp.y) / 2;
|
||||
}
|
||||
} else {
|
||||
// handle notes
|
||||
switch (_dragDirection) {
|
||||
case HandleDirection.Left: {
|
||||
direction.x = -1;
|
||||
fixedPoint.x = maxX;
|
||||
draggingPoint.x = minX + deltaX;
|
||||
rect.w = fixedPoint.x - draggingPoint.x;
|
||||
break;
|
||||
}
|
||||
case HandleDirection.Right: {
|
||||
direction.x = 1;
|
||||
fixedPoint.x = minX;
|
||||
draggingPoint.x = maxX + deltaX;
|
||||
rect.w = draggingPoint.x - fixedPoint.x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
scale.x = rect.w / original.w;
|
||||
flip.x = scale.x < 0 ? -1 : 1;
|
||||
|
||||
if (Math.abs(rect.w) < NOTE_MIN_WIDTH) {
|
||||
rect.w = NOTE_MIN_WIDTH * flip.x;
|
||||
scale.x = rect.w / original.w;
|
||||
draggingPoint.x = fixedPoint.x + rect.w * direction.x;
|
||||
}
|
||||
|
||||
rect.cx = (draggingPoint.x + fixedPoint.x) / 2;
|
||||
}
|
||||
|
||||
const width = Math.abs(rect.w);
|
||||
const height = Math.abs(rect.h);
|
||||
const x = rect.cx - width / 2;
|
||||
const y = rect.cy - height / 2;
|
||||
|
||||
_currentRect.x = x;
|
||||
_currentRect.y = y;
|
||||
_currentRect.width = width;
|
||||
_currentRect.height = height;
|
||||
|
||||
const newBounds = new Map<
|
||||
string,
|
||||
{
|
||||
bound: Bound;
|
||||
path?: PointLocation[];
|
||||
matrix?: DOMMatrix;
|
||||
}
|
||||
>();
|
||||
|
||||
let process: (value: SelectableProps, key: string) => void;
|
||||
|
||||
if (isCorner || isAll || isEdgeAndCorner) {
|
||||
if (this._bounds.size === 1) {
|
||||
process = (_, id) => {
|
||||
newBounds.set(id, {
|
||||
bound: new Bound(x, y, width, height),
|
||||
});
|
||||
};
|
||||
} else {
|
||||
const fp = fixedPoint.matrixTransform(m0);
|
||||
const m2 = new DOMMatrix()
|
||||
.translateSelf(fp.x, fp.y)
|
||||
.rotateSelf(_rotate)
|
||||
.translateSelf(-fp.x, -fp.y)
|
||||
.scaleSelf(scale.x, scale.y, 1, fp.x, fp.y, 0)
|
||||
.translateSelf(fp.x, fp.y)
|
||||
.rotateSelf(-_rotate)
|
||||
.translateSelf(-fp.x, -fp.y);
|
||||
|
||||
// TODO: on same rotate
|
||||
process = ({ bound: { x, y, w, h }, path }, id) => {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
const center = new DOMPoint(cx, cy).matrixTransform(m2);
|
||||
const newWidth = Math.abs(w * scale.x);
|
||||
const newHeight = Math.abs(h * scale.y);
|
||||
|
||||
newBounds.set(id, {
|
||||
bound: new Bound(
|
||||
center.x - newWidth / 2,
|
||||
center.y - newHeight / 2,
|
||||
newWidth,
|
||||
newHeight
|
||||
),
|
||||
matrix: m2,
|
||||
path,
|
||||
});
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// include notes, <---->
|
||||
const m2 = new DOMMatrix().scaleSelf(
|
||||
scale.x,
|
||||
scale.y,
|
||||
1,
|
||||
fixedPoint.x,
|
||||
fixedPoint.y,
|
||||
0
|
||||
);
|
||||
process = ({ bound: { x, y, w, h }, rotate = 0, path }, id) => {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
|
||||
const center = new DOMPoint(cx, cy).matrixTransform(m2);
|
||||
|
||||
let newWidth: number;
|
||||
let newHeight: number;
|
||||
|
||||
// TODO: determine if it is a note
|
||||
if (rotate) {
|
||||
const { width } = getQuadBoundWithRotation({ x, y, w, h, rotate });
|
||||
const hrw = width / 2;
|
||||
|
||||
center.y = cy;
|
||||
|
||||
if (_currentRect.width <= width) {
|
||||
newWidth = w * (_currentRect.width / width);
|
||||
newHeight = newWidth / (w / h);
|
||||
center.x = _currentRect.left + _currentRect.width / 2;
|
||||
} else {
|
||||
const p = (cx - hrw - _originalRect.left) / _originalRect.width;
|
||||
const lx = _currentRect.left + p * _currentRect.width + hrw;
|
||||
center.x = Math.max(
|
||||
_currentRect.left + hrw,
|
||||
Math.min(lx, _currentRect.left + _currentRect.width - hrw)
|
||||
);
|
||||
newWidth = w;
|
||||
newHeight = h;
|
||||
}
|
||||
} else {
|
||||
newWidth = Math.abs(w * scale.x);
|
||||
newHeight = Math.abs(h * scale.y);
|
||||
}
|
||||
|
||||
newBounds.set(id, {
|
||||
bound: new Bound(
|
||||
center.x - newWidth / 2,
|
||||
center.y - newHeight / 2,
|
||||
newWidth,
|
||||
newHeight
|
||||
),
|
||||
matrix: m2,
|
||||
path,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
this._bounds.forEach(process);
|
||||
this._onResizeMove(newBounds, this._dragDirection);
|
||||
}
|
||||
|
||||
private _onRotate(shiftKey = false) {
|
||||
const {
|
||||
_originalRect: { left: minX, top: minY, right: maxX, bottom: maxY },
|
||||
_dragPos: {
|
||||
start: { x: startX, y: startY },
|
||||
end: { x: endX, y: endY },
|
||||
},
|
||||
_origin: { x: centerX, y: centerY },
|
||||
_rotate,
|
||||
} = this;
|
||||
|
||||
const startRad = Math.atan2(startY - centerY, startX - centerX);
|
||||
const endRad = Math.atan2(endY - centerY, endX - centerX);
|
||||
let deltaRad = endRad - startRad;
|
||||
|
||||
// snap angle
|
||||
// 15deg * n = 0, 15, 30, 45, ... 360
|
||||
if (shiftKey) {
|
||||
const prevRad = (_rotate * Math.PI) / 180;
|
||||
let angle = prevRad + deltaRad;
|
||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
deltaRad = angle - prevRad;
|
||||
}
|
||||
|
||||
const delta = (deltaRad * 180) / Math.PI;
|
||||
|
||||
let x = endX;
|
||||
let y = endY;
|
||||
if (shiftKey) {
|
||||
const point = new DOMPoint(startX, startY).matrixTransform(
|
||||
new DOMMatrix()
|
||||
.translateSelf(centerX, centerY)
|
||||
.rotateSelf(delta)
|
||||
.translateSelf(-centerX, -centerY)
|
||||
);
|
||||
x = point.x;
|
||||
y = point.y;
|
||||
}
|
||||
|
||||
this._onRotateMove(
|
||||
// center of element in suface
|
||||
{ x: (minX + maxX) / 2, y: (minY + maxY) / 2 },
|
||||
delta
|
||||
);
|
||||
|
||||
this._dragPos.start = { x, y };
|
||||
this._rotate += delta;
|
||||
}
|
||||
|
||||
onPressShiftKey(pressed: boolean) {
|
||||
if (!this._target) return;
|
||||
if (this._locked) return;
|
||||
|
||||
if (this._shiftKey === pressed) return;
|
||||
this._shiftKey = pressed;
|
||||
|
||||
const proportional = this._proportional || this._shiftKey;
|
||||
|
||||
if (this._rotation) {
|
||||
this._onRotate(proportional);
|
||||
return;
|
||||
}
|
||||
|
||||
this._onResize(proportional);
|
||||
}
|
||||
|
||||
updateBounds(bounds: Map<string, SelectableProps>) {
|
||||
this._bounds = bounds;
|
||||
}
|
||||
|
||||
updateRectPosition(delta: { x: number; y: number }) {
|
||||
this._currentRect.x += delta.x;
|
||||
this._currentRect.y += delta.y;
|
||||
this._originalRect.x = this._currentRect.x;
|
||||
this._originalRect.y = this._currentRect.y;
|
||||
|
||||
return this._originalRect;
|
||||
}
|
||||
|
||||
updateState(
|
||||
resizeMode: ResizeMode,
|
||||
rotate: number,
|
||||
zoom: number,
|
||||
position?: { x: number; y: number },
|
||||
originalRect?: DOMRect,
|
||||
proportion = false
|
||||
) {
|
||||
this._resizeMode = resizeMode;
|
||||
this._rotate = rotate;
|
||||
this._zoom = zoom;
|
||||
this._proportion = proportion;
|
||||
|
||||
if (position) {
|
||||
this._currentRect.x = position.x;
|
||||
this._currentRect.y = position.y;
|
||||
this._originalRect.x = this._currentRect.x;
|
||||
this._originalRect.y = this._currentRect.y;
|
||||
}
|
||||
|
||||
if (originalRect) {
|
||||
this._originalRect = originalRect;
|
||||
this._aspectRatio = originalRect.width / originalRect.height;
|
||||
this._currentRect = DOMRect.fromRect(originalRect);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { TextUtils } from '@blocksuite/affine-block-surface';
|
||||
import type { RichText } from '@blocksuite/affine-components/rich-text';
|
||||
import type { ConnectorElementModel } from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { almostEqual } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
RANGE_SYNC_EXCLUDE_ATTR,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import {
|
||||
assertExists,
|
||||
Bound,
|
||||
Vec,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { DocCollection } from '@blocksuite/store';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
|
||||
const HORIZONTAL_PADDING = 2;
|
||||
const VERTICAL_PADDING = 2;
|
||||
const BORDER_WIDTH = 1;
|
||||
|
||||
export class EdgelessConnectorLabelEditor extends WithDisposable(
|
||||
ShadowlessElement
|
||||
) {
|
||||
static override styles = css`
|
||||
.edgeless-connector-label-editor {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform-origin: center;
|
||||
z-index: 10;
|
||||
padding: ${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px;
|
||||
border: ${BORDER_WIDTH}px solid var(--affine-primary-color, #1e96eb);
|
||||
background: var(--affine-background-primary-color, #fff);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
|
||||
box-sizing: border-box;
|
||||
overflow: visible;
|
||||
|
||||
.inline-editor {
|
||||
white-space: pre-wrap !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.inline-editor span {
|
||||
word-break: normal !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
}
|
||||
|
||||
.edgeless-connector-label-editor-placeholder {
|
||||
pointer-events: none;
|
||||
color: var(--affine-text-disable-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
private _isComposition = false;
|
||||
|
||||
private _keeping = false;
|
||||
|
||||
private _resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
private _updateLabelRect = () => {
|
||||
const { connector, edgeless } = this;
|
||||
if (!connector || !edgeless) return;
|
||||
|
||||
const newWidth = this.inlineEditorContainer.scrollWidth;
|
||||
const newHeight = this.inlineEditorContainer.scrollHeight;
|
||||
const center = connector.getPointByOffsetDistance(
|
||||
connector.labelOffset.distance
|
||||
);
|
||||
const bounds = Bound.fromCenter(center, newWidth, newHeight);
|
||||
const labelXYWH = bounds.toXYWH();
|
||||
|
||||
if (
|
||||
!connector.labelXYWH ||
|
||||
labelXYWH.some((p, i) => !almostEqual(p, connector.labelXYWH![i]))
|
||||
) {
|
||||
edgeless.service.updateElement(connector.id, {
|
||||
labelXYWH,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
get inlineEditor() {
|
||||
assertExists(this.richText.inlineEditor);
|
||||
return this.richText.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineEditorContainer() {
|
||||
return this.inlineEditor.rootElement;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._resizeObserver?.disconnect();
|
||||
this._resizeObserver = null;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { edgeless, connector } = this;
|
||||
const { dispatcher } = edgeless;
|
||||
assertExists(dispatcher);
|
||||
|
||||
this._resizeObserver = new ResizeObserver(() => {
|
||||
this._updateLabelRect();
|
||||
this.requestUpdate();
|
||||
});
|
||||
this._resizeObserver.observe(this.richText);
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
this.inlineEditor.selectAll();
|
||||
|
||||
this.inlineEditor.slots.renderComplete.on(() => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
this.disposables.add(
|
||||
dispatcher.add('keyDown', ctx => {
|
||||
const state = ctx.get('keyboardState');
|
||||
const { key, ctrlKey, metaKey, altKey, shiftKey, isComposing } =
|
||||
state.raw;
|
||||
const onlyCmd = (ctrlKey || metaKey) && !altKey && !shiftKey;
|
||||
const isModEnter = onlyCmd && key === 'Enter';
|
||||
const isEscape = key === 'Escape';
|
||||
if (!isComposing && (isModEnter || isEscape)) {
|
||||
this.inlineEditorContainer.blur();
|
||||
|
||||
edgeless.service.selection.set({
|
||||
elements: [connector.id],
|
||||
editing: false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
edgeless.service.surface.elementUpdated.on(({ id }) => {
|
||||
if (id === connector.id) this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
edgeless.service.viewport.viewportUpdated.on(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(dispatcher.add('click', () => true));
|
||||
this.disposables.add(dispatcher.add('doubleClick', () => true));
|
||||
|
||||
this.disposables.add(() => {
|
||||
if (connector.text) {
|
||||
const text = connector.text.toString();
|
||||
const trimed = text.trim();
|
||||
const len = trimed.length;
|
||||
if (len === 0) {
|
||||
// reset
|
||||
edgeless.service.updateElement(connector.id, {
|
||||
text: undefined,
|
||||
labelXYWH: undefined,
|
||||
labelOffset: undefined,
|
||||
});
|
||||
} else if (len < text.length) {
|
||||
edgeless.service.updateElement(connector.id, {
|
||||
// @TODO: trim in Y.Text?
|
||||
text: new DocCollection.Y.Text(trimed),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
connector.lableEditing = false;
|
||||
|
||||
edgeless.service.selection.set({
|
||||
elements: [],
|
||||
editing: false,
|
||||
});
|
||||
});
|
||||
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'blur',
|
||||
() => {
|
||||
if (this._keeping) return;
|
||||
this.remove();
|
||||
}
|
||||
);
|
||||
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'compositionstart',
|
||||
() => {
|
||||
this._isComposition = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
);
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'compositionend',
|
||||
() => {
|
||||
this._isComposition = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
);
|
||||
|
||||
connector.lableEditing = true;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this.richText?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { connector } = this;
|
||||
const {
|
||||
labelOffset: { distance },
|
||||
labelStyle: {
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
textAlign,
|
||||
color: labelColor,
|
||||
},
|
||||
labelConstraints: { hasMaxWidth, maxWidth },
|
||||
} = connector;
|
||||
|
||||
const lineHeight = TextUtils.getLineHeight(
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight
|
||||
);
|
||||
const { translateX, translateY, zoom } = this.edgeless.service.viewport;
|
||||
const [x, y] = Vec.mul(connector.getPointByOffsetDistance(distance), zoom);
|
||||
const transformOperation = [
|
||||
'translate(-50%, -50%)',
|
||||
`translate(${translateX}px, ${translateY}px)`,
|
||||
`translate(${x}px, ${y}px)`,
|
||||
`scale(${zoom})`,
|
||||
];
|
||||
|
||||
const isEmpty = !connector.text?.length && !this._isComposition;
|
||||
const color = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(labelColor, '#000000');
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="edgeless-connector-label-editor"
|
||||
style=${styleMap({
|
||||
fontFamily: `"${fontFamily}"`,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
textAlign,
|
||||
lineHeight: `${lineHeight}px`,
|
||||
maxWidth: hasMaxWidth
|
||||
? `${maxWidth + BORDER_WIDTH * 2 + HORIZONTAL_PADDING * 2}px`
|
||||
: 'initial',
|
||||
color,
|
||||
transform: transformOperation.join(' '),
|
||||
})}
|
||||
>
|
||||
<rich-text
|
||||
.yText=${connector.text}
|
||||
.enableFormat=${false}
|
||||
style=${isEmpty
|
||||
? styleMap({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
padding: `${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px`,
|
||||
})
|
||||
: nothing}
|
||||
></rich-text>
|
||||
${isEmpty
|
||||
? html`
|
||||
<span class="edgeless-connector-label-editor-placeholder">
|
||||
Add text
|
||||
</span>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setKeeping(keeping: boolean) {
|
||||
this._keeping = keeping;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor connector!: ConnectorElementModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText!: RichText;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-connector-label-editor': EdgelessConnectorLabelEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { RichText } from '@blocksuite/affine-components/rich-text';
|
||||
import { FrameBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
RANGE_SYNC_EXCLUDE_ATTR,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import { assertExists, Bound, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
AFFINE_FRAME_TITLE_WIDGET,
|
||||
type AffineFrameTitleWidget,
|
||||
} from '../../../widgets/frame-title/index.js';
|
||||
import { frameTitleStyleVars } from '../../../widgets/frame-title/styles.js';
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
|
||||
export class EdgelessFrameTitleEditor extends WithDisposable(
|
||||
ShadowlessElement
|
||||
) {
|
||||
static override styles = css`
|
||||
.frame-title-editor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transform-origin: top left;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
padding: 0 4px;
|
||||
outline: none;
|
||||
z-index: 1;
|
||||
border: 1px solid var(--affine-primary-color);
|
||||
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
|
||||
overflow: hidden;
|
||||
font-family: var(--affine-font-family);
|
||||
}
|
||||
`;
|
||||
|
||||
get editorHost() {
|
||||
return this.edgeless.host;
|
||||
}
|
||||
|
||||
get inlineEditor() {
|
||||
return this.richText?.inlineEditor;
|
||||
}
|
||||
|
||||
private _unmount() {
|
||||
// dispose in advance to avoid execute `this.remove()` twice
|
||||
this.disposables.dispose();
|
||||
this.edgeless.service.selection.set({
|
||||
elements: [],
|
||||
editing: false,
|
||||
});
|
||||
this.remove();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
const dispatcher = this.edgeless.dispatcher;
|
||||
assertExists(dispatcher);
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
if (!this.inlineEditor) return;
|
||||
|
||||
this.inlineEditor.selectAll();
|
||||
|
||||
this.inlineEditor.slots.renderComplete.on(() => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
this.disposables.add(
|
||||
dispatcher.add('keyDown', ctx => {
|
||||
const state = ctx.get('keyboardState');
|
||||
if (state.raw.key === 'Enter' && !state.raw.isComposing) {
|
||||
this._unmount();
|
||||
return true;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
return false;
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
this.edgeless.service.viewport.viewportUpdated.on(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(dispatcher.add('click', () => true));
|
||||
this.disposables.add(dispatcher.add('doubleClick', () => true));
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditor.rootElement,
|
||||
'blur',
|
||||
() => {
|
||||
this._unmount();
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this.richText?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const rootBlockId = this.editorHost.doc.root?.id;
|
||||
if (!rootBlockId) return nothing;
|
||||
|
||||
const viewport = this.edgeless.service.viewport;
|
||||
const bound = Bound.deserialize(this.frameModel.xywh);
|
||||
const [x, y] = viewport.toViewCoord(bound.x, bound.y);
|
||||
const isInner = this.edgeless.service.gfx.grid.has(
|
||||
this.frameModel.elementBound,
|
||||
true,
|
||||
true,
|
||||
model => model !== this.frameModel && model instanceof FrameBlockModel
|
||||
);
|
||||
|
||||
const frameTitleWidget = this.edgeless.std.view.getWidget(
|
||||
AFFINE_FRAME_TITLE_WIDGET,
|
||||
rootBlockId
|
||||
) as AffineFrameTitleWidget | null;
|
||||
|
||||
if (!frameTitleWidget) return nothing;
|
||||
|
||||
const frameTitle = frameTitleWidget.getFrameTitle(this.frameModel);
|
||||
|
||||
const colors = frameTitle?.colors ?? {
|
||||
background: cssVarV2('edgeless/frame/background/white'),
|
||||
text: 'var(--affine-text-primary-color)',
|
||||
};
|
||||
|
||||
const inlineEditorStyle = styleMap({
|
||||
fontSize: frameTitleStyleVars.fontSize + 'px',
|
||||
position: 'absolute',
|
||||
left: (isInner ? x + 4 : x) + 'px',
|
||||
top: (isInner ? y + 4 : y - (frameTitleStyleVars.height + 8 / 2)) + 'px',
|
||||
minWidth: '8px',
|
||||
height: frameTitleStyleVars.height + 'px',
|
||||
background: colors.background,
|
||||
color: colors.text,
|
||||
});
|
||||
|
||||
const richTextStyle = styleMap({
|
||||
height: 'fit-content',
|
||||
});
|
||||
|
||||
return html`<div class="frame-title-editor" style=${inlineEditorStyle}>
|
||||
<rich-text
|
||||
.yText=${this.frameModel.title.yText}
|
||||
.enableFormat=${false}
|
||||
.enableAutoScrollHorizontally=${false}
|
||||
style=${richTextStyle}
|
||||
></rich-text>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor frameModel!: FrameBlockModel;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText: RichText | null = null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-frame-title-editor': EdgelessFrameTitleEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
GROUP_TITLE_FONT_SIZE,
|
||||
GROUP_TITLE_OFFSET,
|
||||
GROUP_TITLE_PADDING,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { RichText } from '@blocksuite/affine-components/rich-text';
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
RANGE_SYNC_EXCLUDE_ATTR,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import { assertExists, Bound, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
|
||||
export class EdgelessGroupTitleEditor extends WithDisposable(
|
||||
ShadowlessElement
|
||||
) {
|
||||
get inlineEditor() {
|
||||
assertExists(this.richText.inlineEditor);
|
||||
return this.richText.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineEditorContainer() {
|
||||
return this.inlineEditor.rootElement;
|
||||
}
|
||||
|
||||
private _unmount() {
|
||||
// dispose in advance to avoid execute `this.remove()` twice
|
||||
this.disposables.dispose();
|
||||
this.group.showTitle = true;
|
||||
this.edgeless.service.selection.set({
|
||||
elements: [this.group.id],
|
||||
editing: false,
|
||||
});
|
||||
this.remove();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
const dispatcher = this.edgeless.dispatcher;
|
||||
assertExists(dispatcher);
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
this.inlineEditor.selectAll();
|
||||
|
||||
this.group.showTitle = false;
|
||||
|
||||
this.inlineEditor.slots.renderComplete.on(() => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
this.disposables.add(
|
||||
dispatcher.add('keyDown', ctx => {
|
||||
const state = ctx.get('keyboardState');
|
||||
if (state.raw.key === 'Enter' && !state.raw.isComposing) {
|
||||
this._unmount();
|
||||
return true;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
return false;
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
this.edgeless.service.viewport.viewportUpdated.on(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(dispatcher.add('click', () => true));
|
||||
this.disposables.add(dispatcher.add('doubleClick', () => true));
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'blur',
|
||||
() => {
|
||||
this._unmount();
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this.richText?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.group.externalXYWH) {
|
||||
console.error('group.externalXYWH is not set');
|
||||
return nothing;
|
||||
}
|
||||
const viewport = this.edgeless.service.viewport;
|
||||
const bound = Bound.deserialize(this.group.externalXYWH);
|
||||
const [x, y] = viewport.toViewCoord(bound.x, bound.y);
|
||||
|
||||
const inlineEditorStyle = styleMap({
|
||||
transformOrigin: 'top left',
|
||||
borderRadius: '2px',
|
||||
width: 'fit-content',
|
||||
maxHeight: '30px',
|
||||
height: 'fit-content',
|
||||
padding: `${GROUP_TITLE_PADDING[1]}px ${GROUP_TITLE_PADDING[0]}px`,
|
||||
fontSize: GROUP_TITLE_FONT_SIZE + 'px',
|
||||
position: 'absolute',
|
||||
left: x + 'px',
|
||||
top: `${y - GROUP_TITLE_OFFSET + 2}px`,
|
||||
minWidth: '8px',
|
||||
fontFamily: 'var(--affine-font-family)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
background: 'var(--affine-white-10)',
|
||||
outline: 'none',
|
||||
zIndex: '1',
|
||||
border: `1px solid
|
||||
var(--affine-primary-color)`,
|
||||
boxShadow: 'var(--affine-active-shadow)',
|
||||
});
|
||||
return html`<rich-text
|
||||
.yText=${this.group.title}
|
||||
.enableFormat=${false}
|
||||
.enableAutoScrollHorizontally=${false}
|
||||
style=${inlineEditorStyle}
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor group!: GroupElementModel;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText!: RichText;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-group-title-editor': EdgelessGroupTitleEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { CommonUtils, TextUtils } from '@blocksuite/affine-block-surface';
|
||||
import type { RichText } from '@blocksuite/affine-components/rich-text';
|
||||
import type { ShapeElementModel } from '@blocksuite/affine-model';
|
||||
import { MindmapElementModel, TextResizing } from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
RANGE_SYNC_EXCLUDE_ATTR,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import {
|
||||
assertExists,
|
||||
Bound,
|
||||
Vec,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { DocCollection } from '@blocksuite/store';
|
||||
import { html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
import { getSelectedRect } from '../../utils/query.js';
|
||||
|
||||
const { toRadian } = CommonUtils;
|
||||
|
||||
export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
private _keeping = false;
|
||||
|
||||
private _lastXYWH = '';
|
||||
|
||||
private _resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
get inlineEditor() {
|
||||
assertExists(this.richText.inlineEditor);
|
||||
return this.richText.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineEditorContainer() {
|
||||
return this.inlineEditor.rootElement;
|
||||
}
|
||||
|
||||
get isMindMapNode() {
|
||||
return this.element.group instanceof MindmapElementModel;
|
||||
}
|
||||
|
||||
private _initMindmapKeyBindings() {
|
||||
if (!this.isMindMapNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const service = this.edgeless.service;
|
||||
|
||||
this._disposables.addFromEvent(this, 'keydown', evt => {
|
||||
switch (evt.key) {
|
||||
case 'Enter': {
|
||||
evt.stopPropagation();
|
||||
if (evt.shiftKey || evt.isComposing) return;
|
||||
|
||||
(this.ownerDocument.activeElement as HTMLElement).blur();
|
||||
service.selection.set({
|
||||
elements: [this.element.id],
|
||||
editing: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'Esc':
|
||||
case 'Tab': {
|
||||
evt.stopPropagation();
|
||||
(this.ownerDocument.activeElement as HTMLElement).blur();
|
||||
service.selection.set({
|
||||
elements: [this.element.id],
|
||||
editing: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _stashMindMapTree() {
|
||||
if (!this.isMindMapNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mindmap = this.element.group as MindmapElementModel;
|
||||
const pop = mindmap.stashTree(mindmap.tree);
|
||||
|
||||
this._disposables.add(() => {
|
||||
mindmap.layout();
|
||||
pop?.();
|
||||
});
|
||||
}
|
||||
|
||||
private _unmount() {
|
||||
this._resizeObserver?.disconnect();
|
||||
this._resizeObserver = null;
|
||||
|
||||
if (this.element.text) {
|
||||
const text = this.element.text.toString();
|
||||
const trimed = text.trim();
|
||||
const len = trimed.length;
|
||||
if (len === 0) {
|
||||
this.element.text = undefined;
|
||||
} else if (len < text.length) {
|
||||
this.element.text = new DocCollection.Y.Text(trimed);
|
||||
}
|
||||
}
|
||||
|
||||
this.element.textDisplay = true;
|
||||
|
||||
this.remove();
|
||||
this.edgeless.service.selection.set({
|
||||
elements: [],
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
private _updateElementWH() {
|
||||
const bcr = this.richText.getBoundingClientRect();
|
||||
const containerHeight = this.richText.offsetHeight;
|
||||
const containerWidth = this.richText.offsetWidth;
|
||||
const textResizing = this.element.textResizing;
|
||||
|
||||
if (
|
||||
(containerHeight !== this.element.h &&
|
||||
textResizing === TextResizing.AUTO_HEIGHT) ||
|
||||
(textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT &&
|
||||
(containerWidth !== this.element.w ||
|
||||
containerHeight !== this.element.h))
|
||||
) {
|
||||
const [leftTopX, leftTopY] = Vec.rotWith(
|
||||
[this.richText.offsetLeft, this.richText.offsetTop],
|
||||
[bcr.left + bcr.width / 2, bcr.top + bcr.height / 2],
|
||||
toRadian(-this.element.rotate)
|
||||
);
|
||||
|
||||
const [modelLeftTopX, modelLeftTopY] =
|
||||
this.edgeless.service.viewport.toModelCoord(leftTopX, leftTopY);
|
||||
|
||||
this.edgeless.service.updateElement(this.element.id, {
|
||||
xywh: new Bound(
|
||||
modelLeftTopX,
|
||||
modelLeftTopY,
|
||||
textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT
|
||||
? containerWidth
|
||||
: this.element.w,
|
||||
containerHeight
|
||||
).serialize(),
|
||||
});
|
||||
|
||||
if (this._lastXYWH !== this.element.xywh) {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
if (this.isMindMapNode) {
|
||||
const mindmap = this.element.group as MindmapElementModel;
|
||||
|
||||
mindmap.layout();
|
||||
}
|
||||
|
||||
this.richText.style.minHeight = `${containerHeight}px`;
|
||||
}
|
||||
|
||||
this.edgeless.service.selection.set({
|
||||
elements: [this.element.id],
|
||||
editing: true,
|
||||
});
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
const dispatcher = this.edgeless.dispatcher;
|
||||
assertExists(dispatcher);
|
||||
|
||||
this.element.textDisplay = false;
|
||||
|
||||
this.disposables.add(
|
||||
this.edgeless.service.viewport.viewportUpdated.on(() => {
|
||||
this.requestUpdate();
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
this._updateElementWH();
|
||||
})
|
||||
.catch(console.error);
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
dispatcher.add('click', () => {
|
||||
return true;
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
dispatcher.add('doubleClick', () => {
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
if (this.element.group instanceof MindmapElementModel) {
|
||||
this.inlineEditor.selectAll();
|
||||
} else {
|
||||
this.inlineEditor.focusEnd();
|
||||
}
|
||||
|
||||
this.disposables.add(
|
||||
this.inlineEditor.slots.renderComplete.on(() => {
|
||||
this._updateElementWH();
|
||||
})
|
||||
);
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'blur',
|
||||
() => {
|
||||
if (this._keeping) return;
|
||||
this._unmount();
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
this.disposables.addFromEvent(this, 'keydown', evt => {
|
||||
if (evt.key === 'Escape') {
|
||||
requestAnimationFrame(() => {
|
||||
this.edgeless.service.selection.set({
|
||||
elements: [this.element.id],
|
||||
editing: false,
|
||||
});
|
||||
});
|
||||
|
||||
(this.ownerDocument.activeElement as HTMLElement).blur();
|
||||
}
|
||||
});
|
||||
|
||||
this._initMindmapKeyBindings();
|
||||
this._stashMindMapTree();
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this.richText?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.element.text) {
|
||||
console.error('Failed to mount shape editor because of no text.');
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const [verticalPadding, horiPadding] = this.element.padding;
|
||||
const textResizing = this.element.textResizing;
|
||||
const viewport = this.edgeless.service.viewport;
|
||||
const zoom = viewport.zoom;
|
||||
const rect = getSelectedRect([this.element]);
|
||||
const rotate = this.element.rotate;
|
||||
const [leftTopX, leftTopY] = Vec.rotWith(
|
||||
[rect.left, rect.top],
|
||||
[rect.left + rect.width / 2, rect.top + rect.height / 2],
|
||||
toRadian(rotate)
|
||||
);
|
||||
const [x, y] = this.edgeless.service.viewport.toViewCoord(
|
||||
leftTopX,
|
||||
leftTopY
|
||||
);
|
||||
const autoWidth = textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT;
|
||||
const color = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(this.element.color, '#000000');
|
||||
|
||||
const inlineEditorStyle = styleMap({
|
||||
position: 'absolute',
|
||||
left: x + 'px',
|
||||
top: y + 'px',
|
||||
width:
|
||||
textResizing === TextResizing.AUTO_HEIGHT
|
||||
? rect.width + 'px'
|
||||
: 'fit-content',
|
||||
// override rich-text style (height: 100%)
|
||||
height: 'initial',
|
||||
minHeight:
|
||||
textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT
|
||||
? '1em'
|
||||
: `${rect.height}px`,
|
||||
maxWidth:
|
||||
textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT
|
||||
? this.element.maxWidth
|
||||
? `${this.element.maxWidth}px`
|
||||
: undefined
|
||||
: undefined,
|
||||
boxSizing: 'border-box',
|
||||
fontSize: this.element.fontSize + 'px',
|
||||
fontFamily: TextUtils.wrapFontFamily(this.element.fontFamily),
|
||||
fontWeight: this.element.fontWeight,
|
||||
lineHeight: 'normal',
|
||||
outline: 'none',
|
||||
transform: `scale(${zoom}, ${zoom}) rotate(${rotate}deg)`,
|
||||
transformOrigin: 'top left',
|
||||
color,
|
||||
padding: `${verticalPadding}px ${horiPadding}px`,
|
||||
textAlign: this.element.textAlign,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '100%',
|
||||
alignItems:
|
||||
this.element.textVerticalAlign === 'center'
|
||||
? 'center'
|
||||
: this.element.textVerticalAlign === 'bottom'
|
||||
? 'end'
|
||||
: 'start',
|
||||
alignContent: 'center',
|
||||
gap: '0',
|
||||
zIndex: '1',
|
||||
});
|
||||
|
||||
this._lastXYWH = this.element.xywh;
|
||||
|
||||
return html` <style>
|
||||
edgeless-shape-text-editor v-text [data-v-text] {
|
||||
overflow-wrap: ${autoWidth ? 'normal' : 'anywhere'};
|
||||
word-break: ${autoWidth ? 'normal' : 'break-word'} !important;
|
||||
white-space: ${autoWidth ? 'pre' : 'pre-wrap'} !important;
|
||||
}
|
||||
|
||||
edgeless-shape-text-editor .inline-editor {
|
||||
min-width: 1px;
|
||||
}
|
||||
</style>
|
||||
<rich-text
|
||||
.yText=${this.element.text}
|
||||
.enableFormat=${false}
|
||||
.enableAutoScrollHorizontally=${false}
|
||||
style=${inlineEditorStyle}
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
setKeeping(keeping: boolean) {
|
||||
this._keeping = keeping;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor element!: ShapeElementModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor mountEditor:
|
||||
| ((
|
||||
element: ShapeElementModel,
|
||||
edgeless: EdgelessRootBlockComponent
|
||||
) => void)
|
||||
| undefined = undefined;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText!: RichText;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-shape-text-editor': EdgelessShapeTextEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { CommonUtils, TextUtils } from '@blocksuite/affine-block-surface';
|
||||
import type { RichText } from '@blocksuite/affine-components/rich-text';
|
||||
import type { TextElementModel } from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
RANGE_SYNC_EXCLUDE_ATTR,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import {
|
||||
assertExists,
|
||||
Bound,
|
||||
Vec,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
import { deleteElements } from '../../utils/crud.js';
|
||||
import { getSelectedRect } from '../../utils/query.js';
|
||||
|
||||
const { toRadian } = CommonUtils;
|
||||
|
||||
export class EdgelessTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
static BORDER_WIDTH = 1;
|
||||
|
||||
static PADDING_HORIZONTAL = 10;
|
||||
|
||||
static PADDING_VERTICAL = 6;
|
||||
|
||||
static PLACEHOLDER_TEXT = 'Type from here';
|
||||
|
||||
static override styles = css`
|
||||
.edgeless-text-editor {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
transform-origin: left top;
|
||||
font-kerning: none;
|
||||
border: ${EdgelessTextEditor.BORDER_WIDTH}px solid
|
||||
var(--affine-primary-color, #1e96eb);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
|
||||
padding: ${EdgelessTextEditor.PADDING_VERTICAL}px
|
||||
${EdgelessTextEditor.PADDING_HORIZONTAL}px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.edgeless-text-editor .inline-editor {
|
||||
white-space: pre-wrap !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.edgeless-text-editor .inline-editor span {
|
||||
word-break: normal !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
}
|
||||
|
||||
.edgeless-text-editor-placeholder {
|
||||
pointer-events: none;
|
||||
color: var(--affine-text-disable-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
private _isComposition = false;
|
||||
|
||||
private _keeping = false;
|
||||
|
||||
private _updateRect = () => {
|
||||
const edgeless = this.edgeless;
|
||||
const element = this.element;
|
||||
|
||||
if (!edgeless || !element) return;
|
||||
|
||||
const newWidth = this.inlineEditorContainer.scrollWidth;
|
||||
const newHeight = this.inlineEditorContainer.scrollHeight;
|
||||
const bound = new Bound(element.x, element.y, newWidth, newHeight);
|
||||
const { x, y, w, h, rotate } = element;
|
||||
|
||||
switch (element.textAlign) {
|
||||
case 'left':
|
||||
{
|
||||
const newPos = this.getCoordsOnLeftAlign(
|
||||
{
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
r: toRadian(rotate),
|
||||
},
|
||||
newWidth,
|
||||
newHeight
|
||||
);
|
||||
|
||||
bound.x = newPos.x;
|
||||
bound.y = newPos.y;
|
||||
}
|
||||
break;
|
||||
case 'center':
|
||||
{
|
||||
const newPos = this.getCoordsOnCenterAlign(
|
||||
{
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
r: toRadian(rotate),
|
||||
},
|
||||
newWidth,
|
||||
newHeight
|
||||
);
|
||||
|
||||
bound.x = newPos.x;
|
||||
bound.y = newPos.y;
|
||||
}
|
||||
break;
|
||||
case 'right':
|
||||
{
|
||||
const newPos = this.getCoordsOnRightAlign(
|
||||
{
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
r: toRadian(rotate),
|
||||
},
|
||||
newWidth,
|
||||
newHeight
|
||||
);
|
||||
|
||||
bound.x = newPos.x;
|
||||
bound.y = newPos.y;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
edgeless.service.updateElement(element.id, {
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
};
|
||||
|
||||
get inlineEditor() {
|
||||
assertExists(this.richText.inlineEditor);
|
||||
return this.richText.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineEditorContainer() {
|
||||
return this.inlineEditor.rootElement;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (!this.edgeless) {
|
||||
console.error('edgeless is not set.');
|
||||
return;
|
||||
}
|
||||
if (!this.element) {
|
||||
console.error('text element is not set.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
const edgeless = this.edgeless;
|
||||
const element = this.element;
|
||||
const { dispatcher } = this.edgeless;
|
||||
assertExists(dispatcher);
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
this.inlineEditor.slots.renderComplete.on(() => {
|
||||
this._updateRect();
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
this.disposables.add(
|
||||
edgeless.service.surface.elementUpdated.on(({ id }) => {
|
||||
if (id === element.id) this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
edgeless.service.viewport.viewportUpdated.on(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(dispatcher.add('click', () => true));
|
||||
this.disposables.add(dispatcher.add('doubleClick', () => true));
|
||||
|
||||
this.disposables.add(() => {
|
||||
element.display = true;
|
||||
|
||||
if (element.text.length === 0) {
|
||||
deleteElements(edgeless, [element]);
|
||||
}
|
||||
|
||||
edgeless.service.selection.set({
|
||||
elements: [],
|
||||
editing: false,
|
||||
});
|
||||
});
|
||||
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'blur',
|
||||
() => !this._keeping && this.remove()
|
||||
);
|
||||
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'compositionstart',
|
||||
() => {
|
||||
this._isComposition = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
);
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'compositionend',
|
||||
() => {
|
||||
this._isComposition = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
);
|
||||
|
||||
element.display = false;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
getContainerOffset() {
|
||||
const { PADDING_VERTICAL, PADDING_HORIZONTAL, BORDER_WIDTH } =
|
||||
EdgelessTextEditor;
|
||||
return `-${PADDING_HORIZONTAL + BORDER_WIDTH}px, -${
|
||||
PADDING_VERTICAL + BORDER_WIDTH
|
||||
}px`;
|
||||
}
|
||||
|
||||
getCoordsOnCenterAlign(
|
||||
rect: { w: number; h: number; r: number; x: number; y: number },
|
||||
w1: number,
|
||||
h1: number
|
||||
): { x: number; y: number } {
|
||||
const centerX = rect.x + rect.w / 2;
|
||||
const centerY = rect.y + rect.h / 2;
|
||||
|
||||
let deltaXPrime = 0;
|
||||
let deltaYPrime = (-rect.h / 2) * Math.cos(rect.r);
|
||||
|
||||
const vX = centerX + deltaXPrime;
|
||||
const vY = centerY + deltaYPrime;
|
||||
|
||||
deltaXPrime = 0;
|
||||
deltaYPrime = (-h1 / 2) * Math.cos(rect.r);
|
||||
|
||||
const newCenterX = vX - deltaXPrime;
|
||||
const newCenterY = vY - deltaYPrime;
|
||||
|
||||
return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 };
|
||||
}
|
||||
|
||||
getCoordsOnLeftAlign(
|
||||
rect: { w: number; h: number; r: number; x: number; y: number },
|
||||
w1: number,
|
||||
h1: number
|
||||
): { x: number; y: number } {
|
||||
const cX = rect.x + rect.w / 2;
|
||||
const cY = rect.y + rect.h / 2;
|
||||
|
||||
let deltaXPrime =
|
||||
(-rect.w / 2) * Math.cos(rect.r) + (rect.h / 2) * Math.sin(rect.r);
|
||||
let deltaYPrime =
|
||||
(-rect.w / 2) * Math.sin(rect.r) - (rect.h / 2) * Math.cos(rect.r);
|
||||
|
||||
const vX = cX + deltaXPrime;
|
||||
const vY = cY + deltaYPrime;
|
||||
|
||||
deltaXPrime = (-w1 / 2) * Math.cos(rect.r) + (h1 / 2) * Math.sin(rect.r);
|
||||
deltaYPrime = (-w1 / 2) * Math.sin(rect.r) - (h1 / 2) * Math.cos(rect.r);
|
||||
|
||||
const newCenterX = vX - deltaXPrime;
|
||||
const newCenterY = vY - deltaYPrime;
|
||||
|
||||
return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 };
|
||||
}
|
||||
|
||||
getCoordsOnRightAlign(
|
||||
rect: { w: number; h: number; r: number; x: number; y: number },
|
||||
w1: number,
|
||||
h1: number
|
||||
): { x: number; y: number } {
|
||||
const centerX = rect.x + rect.w / 2;
|
||||
const centerY = rect.y + rect.h / 2;
|
||||
|
||||
let deltaXPrime =
|
||||
(rect.w / 2) * Math.cos(rect.r) - (-rect.h / 2) * Math.sin(rect.r);
|
||||
let deltaYPrime =
|
||||
(rect.w / 2) * Math.sin(rect.r) + (-rect.h / 2) * Math.cos(rect.r);
|
||||
|
||||
const vX = centerX + deltaXPrime;
|
||||
const vY = centerY + deltaYPrime;
|
||||
|
||||
deltaXPrime = (w1 / 2) * Math.cos(rect.r) - (-h1 / 2) * Math.sin(rect.r);
|
||||
deltaYPrime = (w1 / 2) * Math.sin(rect.r) + (-h1 / 2) * Math.cos(rect.r);
|
||||
|
||||
const newCenterX = vX - deltaXPrime;
|
||||
const newCenterY = vY - deltaYPrime;
|
||||
|
||||
return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 };
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this.richText?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
getVisualPosition(element: TextElementModel) {
|
||||
const { x, y, w, h, rotate } = element;
|
||||
return Vec.rotWith([x, y], [x + w / 2, y + h / 2], toRadian(rotate));
|
||||
}
|
||||
|
||||
override render() {
|
||||
const {
|
||||
text,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
textAlign,
|
||||
rotate,
|
||||
hasMaxWidth,
|
||||
w,
|
||||
} = this.element;
|
||||
const lineHeight = TextUtils.getLineHeight(
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight
|
||||
);
|
||||
const rect = getSelectedRect([this.element]);
|
||||
|
||||
const { translateX, translateY, zoom } = this.edgeless.service.viewport;
|
||||
const [visualX, visualY] = this.getVisualPosition(this.element);
|
||||
const containerOffset = this.getContainerOffset();
|
||||
const transformOperation = [
|
||||
`translate(${translateX}px, ${translateY}px)`,
|
||||
`translate(${visualX * zoom}px, ${visualY * zoom}px)`,
|
||||
`scale(${zoom})`,
|
||||
`rotate(${rotate}deg)`,
|
||||
`translate(${containerOffset})`,
|
||||
];
|
||||
|
||||
const isEmpty = !text.length && !this._isComposition;
|
||||
const color = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(this.element.color, '#000000');
|
||||
|
||||
return html`<div
|
||||
style=${styleMap({
|
||||
transform: transformOperation.join(' '),
|
||||
minWidth: hasMaxWidth ? `${rect.width}px` : 'none',
|
||||
maxWidth: hasMaxWidth ? `${w}px` : 'none',
|
||||
fontFamily: TextUtils.wrapFontFamily(fontFamily),
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
color,
|
||||
textAlign,
|
||||
lineHeight: `${lineHeight}px`,
|
||||
boxSizing: 'content-box',
|
||||
})}
|
||||
class="edgeless-text-editor"
|
||||
>
|
||||
<rich-text
|
||||
.yText=${text}
|
||||
.enableFormat=${false}
|
||||
.enableAutoScrollHorizontally=${false}
|
||||
style=${isEmpty
|
||||
? styleMap({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
padding: `${EdgelessTextEditor.PADDING_VERTICAL}px
|
||||
${EdgelessTextEditor.PADDING_HORIZONTAL}px`,
|
||||
})
|
||||
: nothing}
|
||||
></rich-text>
|
||||
${isEmpty
|
||||
? html`<span class="edgeless-text-editor-placeholder">
|
||||
Type from here
|
||||
</span>`
|
||||
: nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
setKeeping(keeping: boolean) {
|
||||
this._keeping = keeping;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor element!: TextElementModel;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText!: RichText;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-text-editor': EdgelessTextEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
EditPropsStore,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
|
||||
import { SignalWatcher } from '@blocksuite/global/utils';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import {
|
||||
type ColorEvent,
|
||||
GET_DEFAULT_LINE_COLOR,
|
||||
} from '../../panel/color-panel.js';
|
||||
import type { LineWidthEvent } from '../../panel/line-width-panel.js';
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
|
||||
export class EdgelessBrushMenu extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
menu-divider {
|
||||
height: 24px;
|
||||
margin: 0 9px;
|
||||
}
|
||||
`;
|
||||
|
||||
private _props$ = computed(() => {
|
||||
const { color, lineWidth } =
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value.brush;
|
||||
return {
|
||||
color,
|
||||
lineWidth,
|
||||
};
|
||||
});
|
||||
|
||||
type: GfxToolsFullOptionValue['type'] = 'brush';
|
||||
|
||||
override render() {
|
||||
const theme = this.edgeless.std.get(ThemeProvider).theme;
|
||||
const color = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.getColorValue(this._props$.value.color, GET_DEFAULT_LINE_COLOR(theme));
|
||||
|
||||
return html`
|
||||
<edgeless-slide-menu>
|
||||
<div class="menu-content">
|
||||
<edgeless-line-width-panel
|
||||
.selectedSize=${this._props$.value.lineWidth}
|
||||
@select=${(e: LineWidthEvent) =>
|
||||
this.onChange({ lineWidth: e.detail })}
|
||||
>
|
||||
</edgeless-line-width-panel>
|
||||
<menu-divider .vertical=${true}></menu-divider>
|
||||
<edgeless-one-row-color-panel
|
||||
.value=${color}
|
||||
.hasTransparent=${!this.edgeless.doc.awarenessStore.getFlag(
|
||||
'enable_color_picker'
|
||||
)}
|
||||
@select=${(e: ColorEvent) => this.onChange({ color: e.detail })}
|
||||
></edgeless-one-row-color-panel>
|
||||
</div>
|
||||
</edgeless-slide-menu>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onChange!: (props: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-brush-menu': EdgelessBrushMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
EdgelessPenDarkIcon,
|
||||
EdgelessPenLightIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import {
|
||||
EditPropsStore,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { SignalWatcher } from '@blocksuite/global/utils';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { getTooltipWithShortcut } from '../../utils.js';
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
|
||||
export class EdgelessBrushToolButton extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.edgeless-brush-button {
|
||||
height: 100%;
|
||||
}
|
||||
.pen-wrapper {
|
||||
width: 35px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
#edgeless-pen-icon {
|
||||
transition: transform 0.3s ease-in-out;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.edgeless-brush-button:hover #edgeless-pen-icon,
|
||||
.pen-wrapper.active #edgeless-pen-icon {
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
private _color$ = computed(() => {
|
||||
const theme = this.edgeless.std.get(ThemeProvider).theme$.value;
|
||||
return this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value.brush.color,
|
||||
undefined,
|
||||
theme
|
||||
);
|
||||
});
|
||||
|
||||
override enableActiveBackground = true;
|
||||
|
||||
override type = 'brush' as const;
|
||||
|
||||
private _toggleBrushMenu() {
|
||||
if (this.tryDisposePopper()) return;
|
||||
!this.active && this.setEdgelessTool(this.type);
|
||||
const menu = this.createPopper('edgeless-brush-menu', this);
|
||||
Object.assign(menu.element, {
|
||||
edgeless: this.edgeless,
|
||||
onChange: (props: Record<string, unknown>) => {
|
||||
this.edgeless.std.get(EditPropsStore).recordLastProps('brush', props);
|
||||
this.setEdgelessTool('brush');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { active } = this;
|
||||
const appTheme = this.edgeless.std.get(ThemeProvider).app$.value;
|
||||
const icon =
|
||||
appTheme === 'dark' ? EdgelessPenDarkIcon : EdgelessPenLightIcon;
|
||||
const color = this._color$.value;
|
||||
|
||||
return html`
|
||||
<edgeless-toolbar-button
|
||||
class="edgeless-brush-button"
|
||||
.tooltip=${this.popper ? '' : getTooltipWithShortcut('Pen', 'P')}
|
||||
.tooltipOffset=${4}
|
||||
.active=${active}
|
||||
.withHover=${true}
|
||||
@click=${() => this._toggleBrushMenu()}
|
||||
>
|
||||
<div style=${styleMap({ color })} class="pen-wrapper">${icon}</div>
|
||||
</edgeless-toolbar-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-brush-tool-button': EdgelessBrushToolButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
|
||||
// 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;
|
||||
}
|
||||
) {
|
||||
const duration = options?.duration ?? 230;
|
||||
|
||||
if (!popMap.has(reference)) popMap.set(reference, new Map());
|
||||
const elMap = popMap.get(reference);
|
||||
assertExists(elMap);
|
||||
// if there is already a popper, cancel leave transition and apply enter transition
|
||||
if (elMap.has(tagName)) {
|
||||
const popper = elMap.get(tagName);
|
||||
assertExists(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);
|
||||
assertExists(reference.shadowRoot);
|
||||
clipWrapper.append(menu);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import {
|
||||
EditPropsStore,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { assertExists, Bound } from '@blocksuite/global/utils';
|
||||
import {
|
||||
type ReactiveController,
|
||||
type ReactiveControllerHost,
|
||||
render,
|
||||
} from 'lit';
|
||||
|
||||
import type { DraggableShape } from '../../shape/utils.js';
|
||||
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>
|
||||
) {
|
||||
this.host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<DraggableShape>).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 { service, clickThreshold = 1500 } = options;
|
||||
const zoom = service.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.options.service.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;
|
||||
assertExists(overlay);
|
||||
|
||||
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 = options.service.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) {
|
||||
edgelessRect.width = edgeless.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);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
this.host.disposables.add(
|
||||
this.options.service.viewport.viewportUpdated.on(({ 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,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,99 @@
|
||||
import type { Bound, DisposableClass } from '@blocksuite/global/utils';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../../../edgeless-root-block.js';
|
||||
import type { EdgelessRootService } from '../../../../edgeless-root-service.js';
|
||||
|
||||
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: EdgelessRootBlockComponent;
|
||||
service: EdgelessRootService;
|
||||
/**
|
||||
* 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,180 @@
|
||||
import { ArrowRightSmallIcon } from '@blocksuite/affine-components/icons';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { consume } from '@lit/context';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
type EdgelessToolbarSlots,
|
||||
edgelessToolbarSlotsContext,
|
||||
} from '../context.js';
|
||||
|
||||
export class EdgelessSlideMenu extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
max-width: 100%;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.slide-menu-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.menu-container {
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
border-radius: 8px 8px 0 0;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
border-bottom: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior: none;
|
||||
scrollbar-width: none;
|
||||
position: relative;
|
||||
height: calc(var(--menu-height) + 1px);
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
.slide-menu-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
transition: left 0.5s ease-in-out;
|
||||
}
|
||||
.next-slide-button,
|
||||
.previous-slide-button {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
color: var(--affine-icon-color);
|
||||
transition:
|
||||
transform 0.3s ease-in-out,
|
||||
opacity 0.5s ease-in-out;
|
||||
z-index: 12;
|
||||
}
|
||||
.next-slide-button {
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translate(50%, -50%) scale(0.5);
|
||||
}
|
||||
.next-slide-button:hover {
|
||||
cursor: pointer;
|
||||
transform: translate(50%, -50%) scale(1);
|
||||
}
|
||||
.previous-slide-button {
|
||||
opacity: 0;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translate(-50%, -50%) scale(0.5);
|
||||
}
|
||||
.previous-slide-button:hover {
|
||||
cursor: pointer;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
.previous-slide-button svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
`;
|
||||
|
||||
private _handleSlideButtonClick(direction: 'left' | 'right') {
|
||||
const totalWidth = this._slideMenuContent.clientWidth;
|
||||
const currentScrollLeft = this._menuContainer.scrollLeft;
|
||||
const menuWidth = this._menuContainer.clientWidth;
|
||||
const newLeft =
|
||||
currentScrollLeft + (direction === 'left' ? -menuWidth : menuWidth);
|
||||
this._menuContainer.scrollTo({
|
||||
left: Math.max(0, Math.min(newLeft, totalWidth)),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
private _handleWheel(event: WheelEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
private _toggleSlideButton() {
|
||||
const scrollLeft = this._menuContainer.scrollLeft;
|
||||
const menuWidth = this._menuContainer.clientWidth;
|
||||
|
||||
const leftMin = 0;
|
||||
const leftMax = this._slideMenuContent.clientWidth - menuWidth + 2; // border is 2
|
||||
this.showPrevious = scrollLeft > leftMin;
|
||||
this.showNext = scrollLeft < leftMax;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
setTimeout(this._toggleSlideButton.bind(this), 0);
|
||||
this._disposables.addFromEvent(this._menuContainer, 'scrollend', () => {
|
||||
this._toggleSlideButton();
|
||||
});
|
||||
this._disposables.add(
|
||||
this.toolbarSlots.resize.on(() => this._toggleSlideButton())
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="slide-menu-wrapper">
|
||||
<div
|
||||
class="previous-slide-button"
|
||||
@click=${() => this._handleSlideButtonClick('left')}
|
||||
style=${styleMap({ opacity: this.showPrevious ? '1' : '0' })}
|
||||
>
|
||||
${ArrowRightSmallIcon}
|
||||
</div>
|
||||
<div
|
||||
class="menu-container"
|
||||
style=${styleMap({ '--menu-height': this.height })}
|
||||
>
|
||||
<div class="slide-menu-content" @wheel=${this._handleWheel}>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style=${styleMap({ opacity: this.showNext ? '1' : '0' })}
|
||||
class="next-slide-button"
|
||||
@click=${() => this._handleSlideButtonClick('right')}
|
||||
>
|
||||
${ArrowRightSmallIcon}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.menu-container')
|
||||
private accessor _menuContainer!: HTMLDivElement;
|
||||
|
||||
@query('.slide-menu-content')
|
||||
private accessor _slideMenuContent!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor height = '40px';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showNext = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showPrevious = false;
|
||||
|
||||
@consume({ context: edgelessToolbarSlotsContext })
|
||||
accessor toolbarSlots!: EdgelessToolbarSlots;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-slide-menu': EdgelessSlideMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { MenuConfig } from '@blocksuite/affine-components/context-menu';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
|
||||
|
||||
/**
|
||||
* Helper function to build a menu configuration for a tool in dense mode
|
||||
*/
|
||||
export type DenseMenuBuilder = (
|
||||
edgeless: EdgelessRootBlockComponent
|
||||
) => MenuConfig;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { menu } from '@blocksuite/affine-components/context-menu';
|
||||
import {
|
||||
ConnectorCWithArrowIcon,
|
||||
ConnectorIcon,
|
||||
ConnectorLWithArrowIcon,
|
||||
ConnectorXWithArrowIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { ConnectorMode } from '@blocksuite/affine-model';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import type { DenseMenuBuilder } from '../common/type.js';
|
||||
|
||||
export const buildConnectorDenseMenu: DenseMenuBuilder = edgeless => {
|
||||
const prevMode =
|
||||
edgeless.std.get(EditPropsStore).lastProps$.value.connector.mode;
|
||||
|
||||
const isSelected = edgeless.gfx.tool.currentToolName$.peek() === 'connector';
|
||||
|
||||
const createSelect =
|
||||
(mode: ConnectorMode, record = true) =>
|
||||
() => {
|
||||
edgeless.gfx.tool.setTool('connector', {
|
||||
mode,
|
||||
});
|
||||
record &&
|
||||
edgeless.std.get(EditPropsStore).recordLastProps('connector', { mode });
|
||||
};
|
||||
|
||||
return menu.subMenu({
|
||||
name: 'Connector',
|
||||
prefix: ConnectorIcon,
|
||||
select: createSelect(prevMode, false),
|
||||
isSelected,
|
||||
options: {
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Curve',
|
||||
prefix: ConnectorCWithArrowIcon,
|
||||
select: createSelect(ConnectorMode.Curve),
|
||||
isSelected: isSelected && prevMode === ConnectorMode.Curve,
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Elbowed',
|
||||
prefix: ConnectorXWithArrowIcon,
|
||||
select: createSelect(ConnectorMode.Orthogonal),
|
||||
isSelected: isSelected && prevMode === ConnectorMode.Orthogonal,
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Straight',
|
||||
prefix: ConnectorLWithArrowIcon,
|
||||
select: createSelect(ConnectorMode.Straight),
|
||||
isSelected: isSelected && prevMode === ConnectorMode.Straight,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
ConnectorCWithArrowIcon,
|
||||
ConnectorLWithArrowIcon,
|
||||
ConnectorXWithArrowIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import {
|
||||
ConnectorMode,
|
||||
DEFAULT_CONNECTOR_COLOR,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditPropsStore,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
|
||||
import { SignalWatcher } from '@blocksuite/global/utils';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { ColorEvent } from '../../panel/color-panel.js';
|
||||
import type { LineWidthEvent } from '../../panel/line-width-panel.js';
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
|
||||
function ConnectorModeButtonGroup(
|
||||
mode: ConnectorMode,
|
||||
setConnectorMode: (props: Record<string, unknown>) => void
|
||||
) {
|
||||
/**
|
||||
* There is little hacky on rendering tooltip.
|
||||
* We don't want either tooltip overlap the top button or tooltip on left.
|
||||
* So we put the lower button's tooltip as the first element of the button group container
|
||||
*/
|
||||
return html`
|
||||
<div class="connector-mode-button-group">
|
||||
<edgeless-tool-icon-button
|
||||
.active=${mode === ConnectorMode.Curve}
|
||||
.activeMode=${'background'}
|
||||
.tooltip=${'Curve'}
|
||||
@click=${() => setConnectorMode({ mode: ConnectorMode.Curve })}
|
||||
>
|
||||
${ConnectorCWithArrowIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
<edgeless-tool-icon-button
|
||||
.active=${mode === ConnectorMode.Orthogonal}
|
||||
.activeMode=${'background'}
|
||||
.tooltip=${'Elbowed'}
|
||||
@click=${() => setConnectorMode({ mode: ConnectorMode.Orthogonal })}
|
||||
>
|
||||
${ConnectorXWithArrowIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
<edgeless-tool-icon-button
|
||||
.active=${mode === ConnectorMode.Straight}
|
||||
.activeMode=${'background'}
|
||||
.tooltip=${'Straight'}
|
||||
@click=${() => setConnectorMode({ mode: ConnectorMode.Straight })}
|
||||
>
|
||||
${ConnectorLWithArrowIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export class EdgelessConnectorMenu extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.connector-submenu-content {
|
||||
display: flex;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.connector-mode-button-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.connector-mode-button-group > edgeless-tool-icon-button svg {
|
||||
fill: var(--affine-icon-color);
|
||||
}
|
||||
|
||||
.submenu-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
margin: 0 16px;
|
||||
background-color: var(--affine-border-color);
|
||||
display: inline-block;
|
||||
}
|
||||
`;
|
||||
|
||||
private _props$ = computed(() => {
|
||||
const { mode, stroke, strokeWidth } =
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value.connector;
|
||||
return { mode, stroke, strokeWidth };
|
||||
});
|
||||
|
||||
override type: GfxToolsFullOptionValue['type'] = 'connector';
|
||||
|
||||
override render() {
|
||||
const { stroke, strokeWidth, mode } = this._props$.value;
|
||||
const connectorModeButtonGroup = ConnectorModeButtonGroup(
|
||||
mode,
|
||||
this.onChange
|
||||
);
|
||||
const color = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.getColorValue(stroke, DEFAULT_CONNECTOR_COLOR);
|
||||
|
||||
return html`
|
||||
<edgeless-slide-menu>
|
||||
<div class="connector-submenu-content">
|
||||
${connectorModeButtonGroup}
|
||||
<div class="submenu-divider"></div>
|
||||
<edgeless-line-width-panel
|
||||
.selectedSize=${strokeWidth}
|
||||
@select=${(e: LineWidthEvent) =>
|
||||
this.onChange({ strokeWidth: e.detail })}
|
||||
>
|
||||
</edgeless-line-width-panel>
|
||||
<div class="submenu-divider"></div>
|
||||
<edgeless-one-row-color-panel
|
||||
.value=${color}
|
||||
.hasTransparent=${!this.edgeless.doc.awarenessStore.getFlag(
|
||||
'enable_color_picker'
|
||||
)}
|
||||
@select=${(e: ColorEvent) => this.onChange({ stroke: e.detail })}
|
||||
></edgeless-one-row-color-panel>
|
||||
</div>
|
||||
</edgeless-slide-menu>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onChange!: (props: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-connector-menu': EdgelessConnectorMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
ConnectorCWithArrowIcon,
|
||||
ConnectorLWithArrowIcon,
|
||||
ConnectorXWithArrowIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { ConnectorMode, getConnectorModeName } from '@blocksuite/affine-model';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import { SignalWatcher } from '@blocksuite/global/utils';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { getTooltipWithShortcut } from '../../utils.js';
|
||||
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
|
||||
|
||||
const IcomMap = {
|
||||
[ConnectorMode.Straight]: ConnectorLWithArrowIcon,
|
||||
[ConnectorMode.Orthogonal]: ConnectorXWithArrowIcon,
|
||||
[ConnectorMode.Curve]: ConnectorCWithArrowIcon,
|
||||
};
|
||||
|
||||
export class EdgelessConnectorToolButton extends QuickToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
}
|
||||
.edgeless-connector-button {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
.arrow-up-icon {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 2px;
|
||||
font-size: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
private _mode$ = computed(() => {
|
||||
return this.edgeless.std.get(EditPropsStore).lastProps$.value.connector
|
||||
.mode;
|
||||
});
|
||||
|
||||
override type = 'connector' as const;
|
||||
|
||||
private _toggleMenu() {
|
||||
if (this.tryDisposePopper()) return;
|
||||
|
||||
const menu = this.createPopper('edgeless-connector-menu', this);
|
||||
menu.element.edgeless = this.edgeless;
|
||||
menu.element.onChange = (props: Record<string, unknown>) => {
|
||||
this.edgeless.std.get(EditPropsStore).recordLastProps('connector', props);
|
||||
this.setEdgelessTool(this.type, {
|
||||
mode: this._mode$.value,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { active } = this;
|
||||
const mode = this._mode$.value;
|
||||
const arrowColor = active ? 'currentColor' : 'var(--affine-icon-secondary)';
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${this.popper
|
||||
? ''
|
||||
: getTooltipWithShortcut(getConnectorModeName(mode), 'C')}
|
||||
.tooltipOffset=${17}
|
||||
.active=${active}
|
||||
.iconContainerPadding=${6}
|
||||
class="edgeless-connector-button"
|
||||
@click=${() => {
|
||||
// don't update tool before toggling menu
|
||||
this._toggleMenu();
|
||||
this.edgeless.gfx.tool.setTool('connector', {
|
||||
mode,
|
||||
});
|
||||
}}
|
||||
>
|
||||
${IcomMap[mode]}
|
||||
<span class="arrow-up-icon" style=${styleMap({ color: arrowColor })}>
|
||||
${ArrowUpIcon}
|
||||
</span>
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-connector-tool-button': EdgelessConnectorToolButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ColorScheme } from '@blocksuite/affine-model';
|
||||
import type { Slot } from '@blocksuite/store';
|
||||
import { createContext } from '@lit/context';
|
||||
|
||||
import type { EdgelessToolbarWidget } from './edgeless-toolbar.js';
|
||||
|
||||
export interface EdgelessToolbarSlots {
|
||||
resize: Slot<{ 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,119 @@
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
HandIcon,
|
||||
SelectIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
|
||||
import { getTooltipWithShortcut } from '../../utils.js';
|
||||
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
|
||||
|
||||
export class EdgelessDefaultToolButton extends QuickToolMixin(LitElement) {
|
||||
static override styles = css`
|
||||
.current-icon {
|
||||
transition: 100ms;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.current-icon > svg {
|
||||
display: block;
|
||||
}
|
||||
.arrow-up-icon {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 2px;
|
||||
font-size: 0;
|
||||
color: var(--affine-icon-secondary);
|
||||
}
|
||||
.active .arrow-up-icon {
|
||||
color: inherit;
|
||||
}
|
||||
`;
|
||||
|
||||
override type: GfxToolsFullOptionValue['type'][] = ['default', 'pan'];
|
||||
|
||||
private _changeTool() {
|
||||
if (this.toolbar.activePopper) {
|
||||
// click manually always closes the popper
|
||||
this.toolbar.activePopper.dispose();
|
||||
}
|
||||
const type = this.edgelessTool?.type;
|
||||
if (type !== 'default' && type !== 'pan') {
|
||||
if (localStorage.defaultTool === 'default') {
|
||||
this.setEdgelessTool('default');
|
||||
} else if (localStorage.defaultTool === 'pan') {
|
||||
this.setEdgelessTool('pan', { panning: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._fadeOut();
|
||||
// wait for animation to finish
|
||||
setTimeout(() => {
|
||||
if (type === 'default') {
|
||||
this.setEdgelessTool('pan', { panning: false });
|
||||
} else if (type === 'pan') {
|
||||
this.setEdgelessTool('default');
|
||||
}
|
||||
this._fadeIn();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private _fadeIn() {
|
||||
this.currentIcon.style.opacity = '1';
|
||||
this.currentIcon.style.transform = `translateY(0px)`;
|
||||
}
|
||||
|
||||
private _fadeOut() {
|
||||
this.currentIcon.style.opacity = '0';
|
||||
this.currentIcon.style.transform = `translateY(-5px)`;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (!localStorage.defaultTool) {
|
||||
localStorage.defaultTool = 'default';
|
||||
}
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const tool = this.edgeless.gfx.tool.currentToolName$.value;
|
||||
if (tool === 'default' || tool === 'pan') {
|
||||
localStorage.defaultTool = tool;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const type = this.edgelessTool?.type;
|
||||
const { active } = this;
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
class="edgeless-default-button ${type} ${active ? 'active' : ''}"
|
||||
.tooltip=${type === 'pan'
|
||||
? getTooltipWithShortcut('Hand', 'H')
|
||||
: getTooltipWithShortcut('Select', 'V')}
|
||||
.tooltipOffset=${17}
|
||||
.active=${active}
|
||||
.iconContainerPadding=${6}
|
||||
@click=${this._changeTool}
|
||||
>
|
||||
<span class="current-icon">
|
||||
${localStorage.defaultTool === 'default' ? SelectIcon : HandIcon}
|
||||
</span>
|
||||
<span class="arrow-up-icon">${ArrowUpIcon}</span>
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.current-icon')
|
||||
accessor currentIcon!: HTMLInputElement;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-default-tool-button': EdgelessDefaultToolButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {
|
||||
type MenuHandler,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import {
|
||||
ArrowLeftSmallIcon,
|
||||
ArrowRightSmallIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
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 { WidgetComponent } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import { debounce } from '@blocksuite/global/utils';
|
||||
import { Slot } from '@blocksuite/store';
|
||||
import { autoPlacement, offset } from '@floating-ui/dom';
|
||||
import { ContextProvider } from '@lit/context';
|
||||
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 type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
import type { MenuPopper } from './common/create-popper.js';
|
||||
import {
|
||||
edgelessToolbarContext,
|
||||
type EdgelessToolbarSlots,
|
||||
edgelessToolbarSlotsContext,
|
||||
edgelessToolbarThemeContext,
|
||||
} from './context.js';
|
||||
import { getQuickTools, getSeniorTools } from './tools.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,
|
||||
EdgelessRootBlockComponent
|
||||
> {
|
||||
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;
|
||||
}
|
||||
.edgeless-toolbar-wrapper[data-app-theme='light'] {
|
||||
${unsafeCSS(lightToolbarStyles.join('\n'))}
|
||||
}
|
||||
.edgeless-toolbar-wrapper[data-app-theme='dark'] {
|
||||
${unsafeCSS(darkToolbarStyles.join('\n'))}
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.brush-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 _moreQuickToolsMenu: MenuHandler | null = null;
|
||||
|
||||
private _moreQuickToolsMenuRef: HTMLElement | null = null;
|
||||
|
||||
@state()
|
||||
accessor containerWidth = 1920;
|
||||
|
||||
private _onContainerResize = debounce(({ w }: { w: number }) => {
|
||||
if (!this.isConnected) return;
|
||||
|
||||
this.slots.resize.emit({ 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);
|
||||
|
||||
private _resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
private _slotsProvider = new ContextProvider(this, {
|
||||
context: edgelessToolbarSlotsContext,
|
||||
initialValue: { resize: new Slot() } satisfies EdgelessToolbarSlots,
|
||||
});
|
||||
|
||||
private _themeProvider = new ContextProvider(this, {
|
||||
context: edgelessToolbarThemeContext,
|
||||
initialValue: ColorScheme.Light,
|
||||
});
|
||||
|
||||
private _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() {
|
||||
return getQuickTools({ edgeless: this.block });
|
||||
}
|
||||
|
||||
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() {
|
||||
return getSeniorTools({
|
||||
edgeless: this.block,
|
||||
toolbarContainer: this.toolbarContainer,
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
return this.gfx.tool.currentToolOption$.value;
|
||||
}
|
||||
|
||||
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}
|
||||
<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}
|
||||
${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}
|
||||
${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))
|
||||
);
|
||||
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;
|
||||
}
|
||||
this.gfx.tool.setTool('default');
|
||||
},
|
||||
},
|
||||
{ global: true }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { _disposables, block, gfx } = this;
|
||||
|
||||
_disposables.add(
|
||||
gfx.viewport.viewportUpdated.on(() => this.requestUpdate())
|
||||
);
|
||||
_disposables.add(
|
||||
block.slots.readonlyUpdated.on(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
_disposables.add(
|
||||
block.slots.toolbarLocked.on(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(
|
||||
this.std.get(EditPropsStore).slots.storageUpdated.on(({ key }) => {
|
||||
if (key === 'presentHideToolbar') {
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { type } = this.edgelessTool || {};
|
||||
if (this.doc.readonly && type !== 'frameNavigator') {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const appTheme = this.std.get(ThemeProvider).app$.value;
|
||||
return html`
|
||||
<div class="edgeless-toolbar-wrapper" data-app-theme=${appTheme}>
|
||||
<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}
|
||||
>
|
||||
<presentation-toolbar
|
||||
.visible=${this.isPresentMode}
|
||||
.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>
|
||||
${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;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-toolbar-widget': EdgelessToolbarWidget;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
EdgelessEraserDarkIcon,
|
||||
EdgelessEraserLightIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
|
||||
import { getTooltipWithShortcut } from '../../utils.js';
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
|
||||
export class EdgelessEraserToolButton extends EdgelessToolbarToolMixin(
|
||||
LitElement
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
height: 100%;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.eraser-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
width: 49px;
|
||||
height: 64px;
|
||||
}
|
||||
#edgeless-eraser-icon {
|
||||
transition: transform 0.3s ease-in-out;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.eraser-button:hover #edgeless-eraser-icon,
|
||||
.eraser-button.active #edgeless-eraser-icon {
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
override enableActiveBackground = true;
|
||||
|
||||
override type: GfxToolsFullOptionValue['type'] = 'eraser';
|
||||
|
||||
override firstUpdated() {
|
||||
this.disposables.add(
|
||||
this.edgeless.bindHotKey(
|
||||
{
|
||||
Escape: () => {
|
||||
if (this.edgelessTool.type === 'eraser') {
|
||||
this.setEdgelessTool({ type: 'default' });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ global: true }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const type = this.edgelessTool?.type;
|
||||
const appTheme = this.edgeless.std.get(ThemeProvider).app$.value;
|
||||
const icon =
|
||||
appTheme === 'dark' ? EdgelessEraserDarkIcon : EdgelessEraserLightIcon;
|
||||
|
||||
return html`
|
||||
<edgeless-toolbar-button
|
||||
class="edgeless-eraser-button"
|
||||
.tooltip=${getTooltipWithShortcut('Eraser', 'E')}
|
||||
.tooltipOffset=${4}
|
||||
.active=${type === 'eraser'}
|
||||
@click=${() => this.setEdgelessTool({ type: 'eraser' })}
|
||||
>
|
||||
<div class="eraser-button">${icon}</div>
|
||||
</edgeless-toolbar-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-eraser-tool-button': EdgelessEraserToolButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const FrameConfig: {
|
||||
name: string;
|
||||
wh: [number, number];
|
||||
}[] = [
|
||||
{ name: '1:1', wh: [1200, 1200] },
|
||||
{ name: '4:3', wh: [1600, 1200] },
|
||||
{ name: '16:9', wh: [1600, 900] },
|
||||
{ name: '2:1', wh: [1600, 800] },
|
||||
];
|
||||
@@ -0,0 +1,30 @@
|
||||
import { menu } from '@blocksuite/affine-components/context-menu';
|
||||
import { FrameIcon } from '@blocksuite/affine-components/icons';
|
||||
|
||||
import type { DenseMenuBuilder } from '../common/type.js';
|
||||
import { FrameConfig } from './config.js';
|
||||
|
||||
export const buildFrameDenseMenu: DenseMenuBuilder = edgeless =>
|
||||
menu.subMenu({
|
||||
name: 'Frame',
|
||||
prefix: FrameIcon,
|
||||
select: () => edgeless.gfx.tool.setTool({ type: 'frame' }),
|
||||
isSelected: edgeless.gfx.tool.currentToolName$.peek() === 'frame',
|
||||
options: {
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Custom',
|
||||
select: () => edgeless.gfx.tool.setTool({ type: 'frame' }),
|
||||
}),
|
||||
...FrameConfig.map(config =>
|
||||
menu.action({
|
||||
name: `Slide ${config.name}`,
|
||||
select: () => {
|
||||
edgeless.gfx.tool.setTool('default');
|
||||
edgeless.service.frame.createFrameOnViewportCenter(config.wh);
|
||||
},
|
||||
})
|
||||
),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
import { FrameConfig } from './config.js';
|
||||
|
||||
export class EdgelessFrameMenu extends EdgelessToolbarToolMixin(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
z-index: -1;
|
||||
}
|
||||
.menu-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.frame-add-button {
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
color: var(--affine-text-primary-color);
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.frame-add-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
transition: background-color 0.23s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.frame-add-button:hover::before {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.custom {
|
||||
width: 60px;
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--affine-border-color);
|
||||
transform: scaleX(0.5);
|
||||
}
|
||||
`;
|
||||
|
||||
override type: GfxToolsFullOptionValue['type'] = 'frame';
|
||||
|
||||
override render() {
|
||||
const { edgeless } = this;
|
||||
return html`
|
||||
<edgeless-slide-menu .showNext=${false}>
|
||||
<div class="menu-content">
|
||||
<div class="frame-add-button custom">Custom</div>
|
||||
<div class="divider"></div>
|
||||
${repeat(
|
||||
FrameConfig,
|
||||
item => item.name,
|
||||
(item, index) => html`
|
||||
<div
|
||||
@click=${() => {
|
||||
edgeless.gfx.tool.setTool('default');
|
||||
edgeless.service.frame.createFrameOnViewportCenter(item.wh);
|
||||
}}
|
||||
class="frame-add-button ${index}"
|
||||
data-name="${item.name}"
|
||||
data-w="${item.wh[0]}"
|
||||
data-h="${item.wh[1]}"
|
||||
>
|
||||
${item.name}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</edgeless-slide-menu>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-frame-menu': EdgelessFrameMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
LargeFrameIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { getTooltipWithShortcut } from '../../../components/utils.js';
|
||||
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
|
||||
|
||||
export class EdgelessFrameToolButton extends QuickToolMixin(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.arrow-up-icon {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 2px;
|
||||
font-size: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
override type: GfxToolsFullOptionValue['type'] = 'frame';
|
||||
|
||||
private _toggleFrameMenu() {
|
||||
if (this.tryDisposePopper()) return;
|
||||
|
||||
const menu = this.createPopper('edgeless-frame-menu', this);
|
||||
menu.element.edgeless = this.edgeless;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const type = this.edgelessTool?.type;
|
||||
const arrowColor =
|
||||
type === 'frame' ? 'currentColor' : 'var(--affine-icon-secondary)';
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
class="edgeless-frame-button"
|
||||
.tooltip=${this.popper ? '' : getTooltipWithShortcut('Frame', 'F')}
|
||||
.tooltipOffset=${17}
|
||||
.active=${type === 'frame'}
|
||||
.iconContainerPadding=${6}
|
||||
@click=${() => {
|
||||
// don't update tool before toggling menu
|
||||
this._toggleFrameMenu();
|
||||
this.setEdgelessTool({ type: 'frame' });
|
||||
}}
|
||||
>
|
||||
${LargeFrameIcon}
|
||||
<span class="arrow-up-icon" style=${styleMap({ color: arrowColor })}>
|
||||
${ArrowUpIcon}
|
||||
</span>
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-frame-tool-button': EdgelessFrameToolButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { menu } from '@blocksuite/affine-components/context-menu';
|
||||
import {
|
||||
LassoFreeHandIcon,
|
||||
LassoPolygonalIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
|
||||
import { LassoMode } from '../../../../../_common/types.js';
|
||||
import type { DenseMenuBuilder } from '../common/type.js';
|
||||
|
||||
export const buildLassoDenseMenu: DenseMenuBuilder = edgeless => {
|
||||
// TODO: active state
|
||||
// const prevMode =
|
||||
// edgeless.service.editPropsStore.getLastProps('lasso').mode ??
|
||||
// LassoMode.FreeHand;
|
||||
|
||||
const isActive = edgeless.gfx.tool.currentToolName$.peek() === 'lasso';
|
||||
|
||||
const createSelect = (mode: LassoMode) => () => {
|
||||
edgeless.gfx.tool.setTool('lasso', { mode });
|
||||
};
|
||||
|
||||
return menu.subMenu({
|
||||
name: 'Lasso',
|
||||
prefix: LassoFreeHandIcon,
|
||||
select: createSelect(LassoMode.FreeHand),
|
||||
isSelected: isActive,
|
||||
options: {
|
||||
items: [
|
||||
menu.action({
|
||||
prefix: LassoFreeHandIcon,
|
||||
name: 'Free',
|
||||
select: createSelect(LassoMode.FreeHand),
|
||||
// isSelected: isActive && prevMode === LassoMode.FreeHand,
|
||||
}),
|
||||
menu.action({
|
||||
prefix: LassoPolygonalIcon,
|
||||
name: 'Polygonal',
|
||||
select: createSelect(LassoMode.Polygonal),
|
||||
// isSelected: isActive && prevMode === LassoMode.Polygonal,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
LassoFreeHandIcon,
|
||||
LassoPolygonalIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { query, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { LassoMode } from '../../../../../_common/types.js';
|
||||
import { getTooltipWithShortcut } from '../../utils.js';
|
||||
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
|
||||
|
||||
export class EdgelessLassoToolButton extends QuickToolMixin(
|
||||
WithDisposable(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
.current-icon {
|
||||
transition: 100ms;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.current-icon > svg {
|
||||
display: block;
|
||||
}
|
||||
.arrow-up-icon {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 2px;
|
||||
font-size: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
private _changeTool = () => {
|
||||
const tool = this.edgelessTool;
|
||||
if (tool.type !== 'lasso') {
|
||||
this.setEdgelessTool({ type: 'lasso', mode: this.curMode });
|
||||
return;
|
||||
}
|
||||
|
||||
this._fadeOut();
|
||||
setTimeout(() => {
|
||||
this.curMode === LassoMode.FreeHand
|
||||
? this.setEdgelessTool({ type: 'lasso', mode: LassoMode.Polygonal })
|
||||
: this.setEdgelessTool({ type: 'lasso', mode: LassoMode.FreeHand });
|
||||
this._fadeIn();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
override type = 'lasso' as const;
|
||||
|
||||
private _fadeIn() {
|
||||
this.currentIcon.style.opacity = '1';
|
||||
this.currentIcon.style.transform = `translateY(0px)`;
|
||||
}
|
||||
|
||||
private _fadeOut() {
|
||||
this.currentIcon.style.opacity = '0';
|
||||
this.currentIcon.style.transform = `translateY(-5px)`;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const tool = this.edgeless.gfx.tool.currentToolOption$.value;
|
||||
|
||||
if (tool?.type === 'lasso') {
|
||||
const { mode } = tool;
|
||||
this.curMode = mode;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const type = this.edgelessTool?.type;
|
||||
const mode = this.curMode === LassoMode.FreeHand ? 'freehand' : 'polygonal';
|
||||
|
||||
const arrowColor =
|
||||
type === 'lasso' ? 'currentColor' : 'var(--affine-icon-secondary)';
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
class="edgeless-lasso-button ${mode}"
|
||||
.tooltip=${getTooltipWithShortcut('Lasso', 'L')}
|
||||
.tooltipOffset=${17}
|
||||
.active=${type === 'lasso'}
|
||||
.iconContainerPadding=${6}
|
||||
@click=${this._changeTool}
|
||||
>
|
||||
<span class="current-icon">
|
||||
${this.curMode === LassoMode.FreeHand
|
||||
? LassoFreeHandIcon
|
||||
: LassoPolygonalIcon}
|
||||
</span>
|
||||
<span class="arrow-up-icon" style=${styleMap({ color: arrowColor })}>
|
||||
${ArrowUpIcon}
|
||||
</span>
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor curMode: LassoMode = LassoMode.FreeHand;
|
||||
|
||||
@query('.current-icon')
|
||||
accessor currentIcon!: HTMLInputElement;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-lasso-tool-button': EdgelessLassoToolButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { menu } from '@blocksuite/affine-components/context-menu';
|
||||
import { LinkIcon } from '@blocksuite/affine-components/icons';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import type { DenseMenuBuilder } from '../common/type.js';
|
||||
|
||||
export const buildLinkDenseMenu: DenseMenuBuilder = edgeless =>
|
||||
menu.action({
|
||||
name: 'Link',
|
||||
prefix: LinkIcon,
|
||||
select: () => {
|
||||
const { insertedLinkType } = edgeless.std.command.exec(
|
||||
'insertLinkByQuickSearch'
|
||||
);
|
||||
|
||||
insertedLinkType
|
||||
?.then(type => {
|
||||
const flavour = type?.flavour;
|
||||
if (!flavour) return;
|
||||
|
||||
edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'toolbar:general',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
type: flavour.split(':')[1],
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { LinkIcon } from '@blocksuite/affine-components/icons';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
|
||||
import { getTooltipWithShortcut } from '../../utils.js';
|
||||
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
|
||||
|
||||
export class EdgelessLinkToolButton extends QuickToolMixin(LitElement) {
|
||||
static override styles = css`
|
||||
.link-icon,
|
||||
.link-icon > svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
override type = 'default' as const;
|
||||
|
||||
private _onClick() {
|
||||
const { insertedLinkType } = this.edgeless.std.command.exec(
|
||||
'insertLinkByQuickSearch'
|
||||
);
|
||||
insertedLinkType
|
||||
?.then(type => {
|
||||
const flavour = type?.flavour;
|
||||
if (!flavour) return;
|
||||
|
||||
this.edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'toolbar:general',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: flavour.split(':')[1],
|
||||
});
|
||||
|
||||
this.edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('LinkedDocCreated', {
|
||||
control: 'links',
|
||||
page: 'whiteboard editor',
|
||||
module: 'edgeless toolbar',
|
||||
segment: 'whiteboard',
|
||||
type: flavour.split(':')[1],
|
||||
other: 'existing doc',
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<edgeless-tool-icon-button
|
||||
.iconContainerPadding="${6}"
|
||||
.tooltip="${getTooltipWithShortcut('Link', '@')}"
|
||||
.tooltipOffset=${17}
|
||||
class="edgeless-link-tool-button"
|
||||
@click=${this._onClick}
|
||||
>
|
||||
<span class="link-icon">${LinkIcon}</span>
|
||||
</edgeless-tool-icon-button>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-link-tool-button': EdgelessLinkToolButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ColorScheme, MindmapStyle } from '@blocksuite/affine-model';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import { type DraggableTool, getMindmapRender } from './basket-elements.js';
|
||||
import {
|
||||
mindMapStyle1Dark,
|
||||
mindMapStyle1Light,
|
||||
mindMapStyle2Dark,
|
||||
mindMapStyle2Light,
|
||||
mindMapStyle3,
|
||||
mindMapStyle4,
|
||||
} from './icons.js';
|
||||
|
||||
export type ToolbarMindmapItem = {
|
||||
type: 'mindmap';
|
||||
icon: TemplateResult;
|
||||
style: MindmapStyle;
|
||||
render: DraggableTool['render'];
|
||||
};
|
||||
|
||||
export const getMindMaps = (theme: ColorScheme): ToolbarMindmapItem[] => [
|
||||
{
|
||||
type: 'mindmap',
|
||||
icon: theme === ColorScheme.Dark ? mindMapStyle1Dark : mindMapStyle1Light,
|
||||
style: MindmapStyle.ONE,
|
||||
render: getMindmapRender(MindmapStyle.ONE),
|
||||
},
|
||||
{
|
||||
type: 'mindmap',
|
||||
icon: mindMapStyle4,
|
||||
style: MindmapStyle.FOUR,
|
||||
render: getMindmapRender(MindmapStyle.FOUR),
|
||||
},
|
||||
{
|
||||
type: 'mindmap',
|
||||
icon: mindMapStyle3,
|
||||
style: MindmapStyle.THREE,
|
||||
render: getMindmapRender(MindmapStyle.THREE),
|
||||
},
|
||||
{
|
||||
type: 'mindmap',
|
||||
icon: theme === 'light' ? mindMapStyle2Light : mindMapStyle2Dark,
|
||||
style: MindmapStyle.TWO,
|
||||
render: getMindmapRender(MindmapStyle.TWO),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,157 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { CanvasElementType } from '@blocksuite/affine-block-surface';
|
||||
import { type MindmapStyle, TextElementModel } from '@blocksuite/affine-model';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import { assertInstanceOf, Bound } from '@blocksuite/global/utils';
|
||||
import { DocCollection } from '@blocksuite/store';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
|
||||
import type { EdgelessRootService } from '../../../edgeless-root-service.js';
|
||||
import { mountTextElementEditor } from '../../../utils/text.js';
|
||||
|
||||
export type ConfigProperty = 'x' | 'y' | 'r' | 's' | 'z' | 'o';
|
||||
export type ConfigState = 'default' | 'active' | 'hover' | 'next';
|
||||
export type ConfigStyle = Partial<Record<ConfigProperty, number | string>>;
|
||||
export type ToolConfig = Record<ConfigState, ConfigStyle>;
|
||||
|
||||
export type DraggableTool = {
|
||||
name: 'text' | 'mindmap';
|
||||
icon: TemplateResult;
|
||||
config: ToolConfig;
|
||||
standardWidth?: number;
|
||||
render: (
|
||||
bound: Bound,
|
||||
edgelessService: EdgelessRootService,
|
||||
edgeless: EdgelessRootBlockComponent
|
||||
) => string;
|
||||
};
|
||||
|
||||
const unitMap = { x: 'px', y: 'px', r: 'deg', s: '', z: '', o: '' };
|
||||
export const textConfig: ToolConfig = {
|
||||
default: { x: -20, y: -8, r: 7.74, s: 0.92, z: 2 },
|
||||
active: { x: -22, y: -9, r: -8, s: 0.92 },
|
||||
hover: { x: -22, y: -9, r: -8, s: 1, z: 3 },
|
||||
next: { x: -22, y: 64, r: 0 },
|
||||
};
|
||||
export const mindmapConfig: ToolConfig = {
|
||||
default: { x: 4, y: -4, s: 1, z: 1, r: -7 },
|
||||
active: { x: 11, y: -14, r: 9, s: 1 },
|
||||
hover: { x: 11, y: -14, r: 9, s: 1.16, z: 3 },
|
||||
next: { y: 64, r: 0 },
|
||||
};
|
||||
|
||||
export const getMindmapRender =
|
||||
(mindmapStyle: MindmapStyle): DraggableTool['render'] =>
|
||||
(bound, edgelessService) => {
|
||||
const [x, y, _, h] = bound.toXYWH();
|
||||
|
||||
const rootW = 145;
|
||||
const rootH = 50;
|
||||
|
||||
const nodeW = 80;
|
||||
const nodeH = 35;
|
||||
|
||||
const centerVertical = y + h / 2;
|
||||
const rootX = x;
|
||||
const rootY = centerVertical - rootH / 2;
|
||||
|
||||
type MindMapNode = {
|
||||
children: MindMapNode[];
|
||||
text: string;
|
||||
xywh: string;
|
||||
};
|
||||
|
||||
const root: MindMapNode = {
|
||||
children: [],
|
||||
text: 'Mind Map',
|
||||
xywh: `[${rootX},${rootY},${rootW},${rootH}]`,
|
||||
};
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const nodeX = x + rootW + 300;
|
||||
const nodeY = centerVertical - nodeH / 2 + (i - 1) * 50;
|
||||
root.children.push({
|
||||
children: [],
|
||||
text: 'Text',
|
||||
xywh: `[${nodeX},${nodeY},${nodeW},${nodeH}]`,
|
||||
});
|
||||
}
|
||||
|
||||
const mindmapId = edgelessService.addElement('mindmap', {
|
||||
style: mindmapStyle,
|
||||
children: root,
|
||||
}) as string;
|
||||
|
||||
edgelessService.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'toolbar:dnd', // for now we use toolbar:dnd for all mindmap creation here
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'mindmap',
|
||||
});
|
||||
|
||||
return mindmapId;
|
||||
};
|
||||
export const textRender: DraggableTool['render'] = (
|
||||
bound,
|
||||
service,
|
||||
edgeless
|
||||
) => {
|
||||
const vCenter = bound.y + bound.h / 2;
|
||||
const w = 100;
|
||||
const h = 32;
|
||||
|
||||
const flag = edgeless.doc.awarenessStore.getFlag('enable_edgeless_text');
|
||||
let id: string;
|
||||
if (flag) {
|
||||
const { textId } = edgeless.std.command.exec('insertEdgelessText', {
|
||||
x: bound.x,
|
||||
y: vCenter - h / 2,
|
||||
});
|
||||
id = textId!;
|
||||
} else {
|
||||
id = service.addElement(CanvasElementType.TEXT, {
|
||||
xywh: new Bound(bound.x, vCenter - h / 2, w, h).serialize(),
|
||||
text: new DocCollection.Y.Text(),
|
||||
});
|
||||
|
||||
edgeless.doc.captureSync();
|
||||
const textElement = edgeless.service.getElementById(id);
|
||||
assertInstanceOf(textElement, TextElementModel);
|
||||
mountTextElementEditor(textElement, edgeless);
|
||||
}
|
||||
|
||||
service.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'toolbar:dnd',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'text',
|
||||
});
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
const toolStyle2StyleObj = (state: ConfigState, style: ConfigStyle = {}) => {
|
||||
const styleObj = {} as Record<string, string>;
|
||||
for (const [key, value] of Object.entries(style)) {
|
||||
styleObj[`--${state}-${key}`] = `${value}${unitMap[key as ConfigProperty]}`;
|
||||
}
|
||||
return styleObj;
|
||||
};
|
||||
export const toolConfig2StyleObj = (config: ToolConfig) => {
|
||||
const styleObj = {} as Record<string, string>;
|
||||
for (const [state, style] of Object.entries(config)) {
|
||||
Object.assign(
|
||||
styleObj,
|
||||
toolStyle2StyleObj(state as ConfigState, {
|
||||
...config.default,
|
||||
...style,
|
||||
})
|
||||
);
|
||||
}
|
||||
return styleObj;
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
|
||||
import { LightLoadingIcon } from '@blocksuite/affine-components/icons';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { css, html } from 'lit';
|
||||
|
||||
import { importMindMapIcon } from './icons.js';
|
||||
|
||||
export class MindMapPlaceholder extends ShadowlessElement {
|
||||
static override styles = css`
|
||||
mindmap-import-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding: 28px 12px 12px;
|
||||
box-sizing: border-box;
|
||||
width: 200px;
|
||||
height: 122px;
|
||||
|
||||
border-radius: 12px;
|
||||
gap: 12px;
|
||||
|
||||
background-color: ${unsafeCSSVarV2('layer/background/secondary')};
|
||||
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
color: ${unsafeCSSVarV2('text/placeholder')};
|
||||
|
||||
box-shadow: 0px 0px 4px 0px rgba(66, 65, 73, 0.14);
|
||||
}
|
||||
|
||||
mindmap-import-placeholder .preview-icon {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
mindmap-import-placeholder .description {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
color: ${unsafeCSSVarV2('text/placeholder')};
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return html`<div class="placeholder-container">
|
||||
<div class="preview-icon">${importMindMapIcon}</div>
|
||||
<div class="description">
|
||||
${LightLoadingIcon}
|
||||
<span>Importing mind map...</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'mindmap-import-placeholder': MindMapPlaceholder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import type { MindmapStyle } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditPropsStore,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import { modelContext, stdContext } from '@blocksuite/block-std';
|
||||
import { ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { Bound } from '@blocksuite/global/utils';
|
||||
import { SignalWatcher } from '@blocksuite/global/utils';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { consume } from '@lit/context';
|
||||
import { computed } from '@preact/signals-core';
|
||||
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 '../../../index.js';
|
||||
import { getTooltipWithShortcut } from '../../utils.js';
|
||||
import { EdgelessDraggableElementController } from '../common/draggable/draggable-element.controller.js';
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
import { getMindMaps, type ToolbarMindmapItem } from './assets.js';
|
||||
import { textRender } from './basket-elements.js';
|
||||
import { importMindMapIcon, textIcon } from './icons.js';
|
||||
import { MindMapPlaceholder } from './mindmap-importing-placeholder.js';
|
||||
|
||||
type TextItem = {
|
||||
type: 'text';
|
||||
icon: TemplateResult;
|
||||
render: typeof textRender;
|
||||
};
|
||||
|
||||
type ImportItem = {
|
||||
type: 'import';
|
||||
icon: TemplateResult;
|
||||
};
|
||||
|
||||
const textItem: TextItem = { type: 'text', icon: textIcon, render: textRender };
|
||||
|
||||
export class EdgelessMindmapMenu extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
z-index: -1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.text-and-mindmap {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px 0px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.thin-divider {
|
||||
width: 1px;
|
||||
transform: scaleX(0.5);
|
||||
height: 48px;
|
||||
background: var(--affine-border-color);
|
||||
}
|
||||
.text-item {
|
||||
width: 60px;
|
||||
}
|
||||
.mindmap-item {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.text-item,
|
||||
.mindmap-item {
|
||||
border-radius: 4px;
|
||||
height: 48px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.text-item > button,
|
||||
.mindmap-item > button {
|
||||
position: absolute;
|
||||
border-radius: inherit;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: grab;
|
||||
padding: 0;
|
||||
}
|
||||
.text-item:hover,
|
||||
.mindmap-item[data-is-active='true'],
|
||||
.mindmap-item:hover {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
.text-item > button.next,
|
||||
.mindmap-item > button.next {
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
`;
|
||||
|
||||
private _style$ = computed(() => {
|
||||
const { style } =
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value.mindmap;
|
||||
return style;
|
||||
});
|
||||
|
||||
draggableController!: EdgelessDraggableElementController<
|
||||
ToolbarMindmapItem | TextItem | ImportItem
|
||||
>;
|
||||
|
||||
override type = 'empty' as const;
|
||||
|
||||
private get _rootBlock(): EdgelessRootBlockComponent {
|
||||
return this.std.view.getBlock(this.model.id) as EdgelessRootBlockComponent;
|
||||
}
|
||||
|
||||
get mindMaps() {
|
||||
return getMindMaps(this.theme);
|
||||
}
|
||||
|
||||
private _importMindMapEntry() {
|
||||
const { draggingElement } = this.draggableController?.states || {};
|
||||
const isBeingDragged = draggingElement?.data.type === 'import';
|
||||
|
||||
return html`<div class="mindmap-item">
|
||||
<button
|
||||
style="opacity: ${isBeingDragged ? 0 : 1}"
|
||||
class="next"
|
||||
@mousedown=${(e: MouseEvent) => {
|
||||
this.draggableController.onMouseDown(e, {
|
||||
preview: importMindMapIcon,
|
||||
data: {
|
||||
type: 'import',
|
||||
icon: importMindMapIcon,
|
||||
},
|
||||
standardWidth: 350,
|
||||
});
|
||||
}}
|
||||
@touchstart=${(e: TouchEvent) => {
|
||||
this.draggableController.onTouchStart(e, {
|
||||
preview: importMindMapIcon,
|
||||
data: {
|
||||
type: 'import',
|
||||
icon: importMindMapIcon,
|
||||
},
|
||||
standardWidth: 350,
|
||||
});
|
||||
}}
|
||||
@click=${() => {
|
||||
this.draggableController.cancel();
|
||||
const viewportBound = this._rootBlock.service.viewport.viewportBounds;
|
||||
|
||||
viewportBound.x += viewportBound.w / 2;
|
||||
viewportBound.y += viewportBound.h / 2;
|
||||
|
||||
this._onImportMindMap(viewportBound);
|
||||
}}
|
||||
>
|
||||
${importMindMapIcon}
|
||||
</button>
|
||||
<affine-tooltip tip-position="top" .offset=${12}>
|
||||
${getTooltipWithShortcut('Support import of FreeMind,OPML.')}
|
||||
</affine-tooltip>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _onImportMindMap(bound: Bound) {
|
||||
const edgelessBlock = this._rootBlock;
|
||||
if (!edgelessBlock) return;
|
||||
|
||||
const placeholder = new MindMapPlaceholder();
|
||||
|
||||
placeholder.style.position = 'absolute';
|
||||
placeholder.style.left = `${bound.x}px`;
|
||||
placeholder.style.top = `${bound.y}px`;
|
||||
|
||||
edgelessBlock.gfxViewportElm.append(placeholder);
|
||||
|
||||
this.onImportMindMap?.(bound)
|
||||
.then(() => {
|
||||
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
page: 'whiteboard editor',
|
||||
type: 'imported mind map',
|
||||
other: 'success',
|
||||
module: 'toolbar',
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
if (e.code === ErrorCode.UserAbortError) return;
|
||||
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
page: 'whiteboard editor',
|
||||
type: 'imported mind map',
|
||||
other: 'failed',
|
||||
module: 'toolbar',
|
||||
});
|
||||
toast(this.edgeless.host, 'Import failed, please try again');
|
||||
console.error(e);
|
||||
})
|
||||
.finally(() => {
|
||||
placeholder.remove();
|
||||
});
|
||||
}
|
||||
|
||||
initDragController() {
|
||||
if (this.draggableController || !this.edgeless) return;
|
||||
this.draggableController = new EdgelessDraggableElementController(this, {
|
||||
service: this.edgeless.service,
|
||||
edgeless: this.edgeless,
|
||||
scopeElement: this,
|
||||
clickToDrag: true,
|
||||
onOverlayCreated: (_layer, element) => {
|
||||
if (element.data.type === 'mindmap') {
|
||||
this.onActiveStyleChange?.(element.data.style);
|
||||
}
|
||||
// a workaround to active mindmap, so that menu cannot be closed by `Escape`
|
||||
this.setEdgelessTool({ type: 'empty' });
|
||||
},
|
||||
onDrop: (element, bound) => {
|
||||
if ('render' in element.data) {
|
||||
const id = element.data.render(
|
||||
bound,
|
||||
this.edgeless.service,
|
||||
this.edgeless
|
||||
);
|
||||
if (element.data.type === 'mindmap') {
|
||||
this.onActiveStyleChange?.(element.data.style);
|
||||
this.setEdgelessTool({ type: 'default' });
|
||||
this.edgeless.gfx.selection.set({ elements: [id], editing: false });
|
||||
} else if (element.data.type === 'text') {
|
||||
this.setEdgelessTool({ type: 'default' });
|
||||
}
|
||||
}
|
||||
|
||||
if (element.data.type === 'import') {
|
||||
this._onImportMindMap?.(bound);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { cancelled, draggingElement, dragOut } =
|
||||
this.draggableController?.states || {};
|
||||
|
||||
const isDraggingText = draggingElement?.data?.type === 'text';
|
||||
const showNextText = dragOut && !cancelled;
|
||||
return html`<edgeless-slide-menu .height=${'64px'}>
|
||||
<div class="text-and-mindmap">
|
||||
<div class="text-item">
|
||||
${isDraggingText
|
||||
? html`<button
|
||||
class="next"
|
||||
style="transform: translateY(${showNextText ? 0 : 64}px)"
|
||||
>
|
||||
${textItem.icon}
|
||||
</button>`
|
||||
: nothing}
|
||||
<button
|
||||
style="opacity: ${isDraggingText ? 0 : 1}"
|
||||
@mousedown=${(e: MouseEvent) =>
|
||||
this.draggableController.onMouseDown(e, {
|
||||
preview: textItem.icon,
|
||||
data: textItem,
|
||||
})}
|
||||
@touchstart=${(e: TouchEvent) =>
|
||||
this.draggableController.onTouchStart(e, {
|
||||
preview: textItem.icon,
|
||||
data: textItem,
|
||||
})}
|
||||
>
|
||||
${textItem.icon}
|
||||
</button>
|
||||
<affine-tooltip tip-position="top" .offset=${12}>
|
||||
${getTooltipWithShortcut('Edgeless Text', 'T')}
|
||||
</affine-tooltip>
|
||||
</div>
|
||||
<div class="thin-divider"></div>
|
||||
<!-- mind map -->
|
||||
${repeat(this.mindMaps, mindMap => {
|
||||
const isDraggingMindMap = draggingElement?.data?.type !== 'text';
|
||||
const draggingEle = draggingElement?.data as ToolbarMindmapItem;
|
||||
const isBeingDragged =
|
||||
isDraggingMindMap && draggingEle?.style === mindMap.style;
|
||||
const showNext = dragOut && !cancelled;
|
||||
const isActive = this._style$.value === mindMap.style;
|
||||
return html`
|
||||
<div class="mindmap-item" data-is-active=${isActive}>
|
||||
${isBeingDragged
|
||||
? html`<button
|
||||
style="transform: translateY(${showNext ? 0 : 64}px)"
|
||||
class="next"
|
||||
>
|
||||
${mindMap.icon}
|
||||
</button>`
|
||||
: nothing}
|
||||
<button
|
||||
style="opacity: ${isBeingDragged ? 0 : 1}"
|
||||
@mousedown=${(e: MouseEvent) => {
|
||||
this.draggableController.onMouseDown(e, {
|
||||
preview: mindMap.icon,
|
||||
data: mindMap,
|
||||
standardWidth: 350,
|
||||
});
|
||||
}}
|
||||
@touchstart=${(e: TouchEvent) => {
|
||||
this.draggableController.onTouchStart(e, {
|
||||
preview: mindMap.icon,
|
||||
data: mindMap,
|
||||
standardWidth: 350,
|
||||
});
|
||||
}}
|
||||
@click=${() => this.onActiveStyleChange?.(mindMap.style)}
|
||||
>
|
||||
${mindMap.icon}
|
||||
</button>
|
||||
<affine-tooltip tip-position="top" .offset=${12}>
|
||||
${getTooltipWithShortcut('Mind Map', 'M')}
|
||||
</affine-tooltip>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
${this.std.doc.awarenessStore.getFlag('enable_mind_map_import')
|
||||
? this._importMindMapEntry()
|
||||
: nothing}
|
||||
</div>
|
||||
</edgeless-slide-menu>`;
|
||||
}
|
||||
|
||||
override updated(changedProperties: Map<PropertyKey, unknown>) {
|
||||
if (!changedProperties.has('edgeless')) return;
|
||||
this.initDragController();
|
||||
}
|
||||
|
||||
@consume({ context: modelContext })
|
||||
accessor model!: BlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onActiveStyleChange!: (style: MindmapStyle) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onImportMindMap!: (bound: Bound) => Promise<void>;
|
||||
|
||||
@consume({ context: stdContext })
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-mindmap-menu': EdgelessMindmapMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import type {
|
||||
MindmapElementModel,
|
||||
MindmapStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditPropsStore,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
|
||||
import type { Bound } from '@blocksuite/global/utils';
|
||||
import { SignalWatcher } from '@blocksuite/global/utils';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { EdgelessDraggableElementController } from '../common/draggable/draggable-element.controller.js';
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
import { getMindMaps } from './assets.js';
|
||||
import {
|
||||
type DraggableTool,
|
||||
getMindmapRender,
|
||||
mindmapConfig,
|
||||
textConfig,
|
||||
textRender,
|
||||
toolConfig2StyleObj,
|
||||
} from './basket-elements.js';
|
||||
import { basketIconDark, basketIconLight, textIcon } from './icons.js';
|
||||
import { importMindmap } from './utils/import-mindmap.js';
|
||||
|
||||
export class EdgelessMindmapToolButton extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.partial-clip {
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
width: calc(100% + 20px);
|
||||
pointer-events: none;
|
||||
padding: 0 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.basket-wrapper {
|
||||
pointer-events: auto;
|
||||
height: 64px;
|
||||
width: 96px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
}
|
||||
.basket,
|
||||
.basket-tool-item {
|
||||
transition: transform 0.3s ease-in-out;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.basket {
|
||||
bottom: 0;
|
||||
height: 17px;
|
||||
width: 76px;
|
||||
}
|
||||
.basket > div,
|
||||
.basket > svg {
|
||||
position: absolute;
|
||||
}
|
||||
.glass {
|
||||
width: 76px;
|
||||
height: 17px;
|
||||
border-radius: 2px;
|
||||
mask: url(#mindmap-basket-body-mask);
|
||||
}
|
||||
.glass.enabled {
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.basket {
|
||||
z-index: 3;
|
||||
}
|
||||
.basket-tool-item {
|
||||
cursor: grab;
|
||||
}
|
||||
.basket-tool-item svg {
|
||||
display: block;
|
||||
}
|
||||
.basket-tool-item {
|
||||
transform: translate(var(--default-x, 0), var(--default-y, 0))
|
||||
rotate(var(--default-r, 0)) scale(var(--default-s, 1));
|
||||
z-index: var(--default-z, 0);
|
||||
}
|
||||
|
||||
.basket-tool-item.next {
|
||||
transform: translate(var(--next-x, 0), var(--next-y, 0))
|
||||
rotate(var(--next-r, 0)) scale(var(--next-s, 1));
|
||||
z-index: var(--next-z, 0);
|
||||
}
|
||||
|
||||
/* active & hover */
|
||||
.basket-wrapper:hover .basket,
|
||||
.basket-wrapper.active .basket {
|
||||
z-index: 0;
|
||||
}
|
||||
.basket-wrapper:hover .basket-tool-item.current,
|
||||
.basket-wrapper.active .basket-tool-item.current {
|
||||
transform: translate(var(--active-x, 0), var(--active-y, 0))
|
||||
rotate(var(--active-r, 0)) scale(var(--active-s, 1));
|
||||
z-index: var(--active-z, 0);
|
||||
}
|
||||
|
||||
.basket-tool-item.next.coming,
|
||||
.basket-wrapper:hover .basket-tool-item.current:hover {
|
||||
transform: translate(var(--hover-x, 0), var(--hover-y, 0))
|
||||
rotate(var(--hover-r, 0)) scale(var(--hover-s, 1));
|
||||
z-index: var(--hover-z, 0);
|
||||
}
|
||||
`;
|
||||
|
||||
private _style$ = computed(() => {
|
||||
const { style } =
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value.mindmap;
|
||||
return style;
|
||||
});
|
||||
|
||||
draggableController!: EdgelessDraggableElementController<DraggableTool>;
|
||||
|
||||
override enableActiveBackground = true;
|
||||
|
||||
override type: GfxToolsFullOptionValue['type'][] = ['empty', 'text'];
|
||||
|
||||
get draggableTools(): DraggableTool[] {
|
||||
const style = this._style$.value;
|
||||
const mindmap =
|
||||
this.mindmaps.find(m => m.style === style) || this.mindmaps[0];
|
||||
return [
|
||||
{
|
||||
name: 'text',
|
||||
icon: textIcon,
|
||||
config: textConfig,
|
||||
standardWidth: 100,
|
||||
render: textRender,
|
||||
},
|
||||
{
|
||||
name: 'mindmap',
|
||||
icon: mindmap.icon,
|
||||
config: mindmapConfig,
|
||||
standardWidth: 350,
|
||||
render: getMindmapRender(style),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
get mindmaps() {
|
||||
return getMindMaps(this.theme);
|
||||
}
|
||||
|
||||
private _toggleMenu() {
|
||||
if (this.tryDisposePopper()) return;
|
||||
this.setEdgelessTool({ type: 'default' });
|
||||
|
||||
const menu = this.createPopper('edgeless-mindmap-menu', this);
|
||||
Object.assign(menu.element, {
|
||||
edgeless: this.edgeless,
|
||||
onActiveStyleChange: (style: MindmapStyle) => {
|
||||
this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', {
|
||||
style,
|
||||
});
|
||||
},
|
||||
onImportMindMap: (bound: Bound) => {
|
||||
return importMindmap(bound).then(mindmap => {
|
||||
const id = this.edgeless.service.addElement('mindmap', {
|
||||
children: mindmap,
|
||||
layoutType: mindmap?.layoutType === 'left' ? 1 : 0,
|
||||
});
|
||||
const element = this.edgeless.service.getElementById(
|
||||
id
|
||||
) as MindmapElementModel;
|
||||
|
||||
this.tryDisposePopper();
|
||||
this.setEdgelessTool({ type: 'default' });
|
||||
this.edgeless.gfx.selection.set({
|
||||
elements: [element.tree.id],
|
||||
editing: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
initDragController() {
|
||||
if (!this.edgeless || !this.toolbarContainer) return;
|
||||
if (this.draggableController) return;
|
||||
this.draggableController = new EdgelessDraggableElementController(this, {
|
||||
service: this.edgeless.service,
|
||||
edgeless: this.edgeless,
|
||||
scopeElement: this.toolbarContainer,
|
||||
standardWidth: 100,
|
||||
clickToDrag: true,
|
||||
onOverlayCreated: (overlay, { data }) => {
|
||||
const tool = this.draggableTools.find(t => t.name === data.name);
|
||||
if (!tool) return;
|
||||
|
||||
// recover the rotation
|
||||
const rotate = tool.config?.hover?.r ?? tool.config?.default?.r ?? 0;
|
||||
overlay.element.style.setProperty('--rotate', rotate + 'deg');
|
||||
setTimeout(() => {
|
||||
overlay.transitionWrapper.style.setProperty(
|
||||
'--rotate',
|
||||
-rotate + 'deg'
|
||||
);
|
||||
}, 50);
|
||||
|
||||
// set the scale (without transition)
|
||||
const scale = tool.config?.hover?.s ?? tool.config?.default?.s ?? 1;
|
||||
overlay.element.style.setProperty('--scale', `${scale}`);
|
||||
|
||||
// a workaround to handle getBoundingClientRect() when the element is rotated
|
||||
const _left = parseInt(overlay.element.style.left);
|
||||
const _top = parseInt(overlay.element.style.top);
|
||||
if (data.name === 'mindmap') {
|
||||
overlay.element.style.left = _left + 3 + 'px';
|
||||
overlay.element.style.top = _top + 5 + 'px';
|
||||
} else if (data.name === 'text') {
|
||||
overlay.element.style.left = _left + 0 + 'px';
|
||||
overlay.element.style.top = _top + 3 + 'px';
|
||||
}
|
||||
this.readyToDrop = true;
|
||||
},
|
||||
onCanceled: overlay => {
|
||||
overlay.transitionWrapper.style.transformOrigin = 'unset';
|
||||
overlay.transitionWrapper.style.setProperty('--rotate', '0deg');
|
||||
this.readyToDrop = false;
|
||||
},
|
||||
onDrop: (el, bound) => {
|
||||
const id = el.data.render(bound, this.edgeless.service, this.edgeless);
|
||||
this.readyToDrop = false;
|
||||
if (el.data.name === 'mindmap') {
|
||||
this.setEdgelessTool({ type: 'default' });
|
||||
this.edgeless.gfx.selection.set({ elements: [id], editing: false });
|
||||
} else if (el.data.name === 'text') {
|
||||
this.setEdgelessTool({ type: 'default' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.edgeless.bindHotKey(
|
||||
{
|
||||
m: () => {
|
||||
const service = this.edgeless.service;
|
||||
if (service.locked) return;
|
||||
if (service.selection.editing) return;
|
||||
|
||||
if (this.readyToDrop) {
|
||||
// change the style
|
||||
const activeIndex = this.mindmaps.findIndex(
|
||||
m => m.style === this._style$.value
|
||||
);
|
||||
const nextIndex = (activeIndex + 1) % this.mindmaps.length;
|
||||
const next = this.mindmaps[nextIndex];
|
||||
this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', {
|
||||
style: next.style,
|
||||
});
|
||||
const tool = this.draggableTools.find(t => t.name === 'mindmap');
|
||||
this.draggableController.updateElementInfo({
|
||||
data: tool,
|
||||
preview: next.icon,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setEdgelessTool({ type: 'empty' });
|
||||
const icon = this.mindmapElement;
|
||||
const { x, y } = service.gfx.tool.lastMousePos$.peek();
|
||||
const { left, top } = this.edgeless.viewport;
|
||||
const clientPos = { x: x + left, y: y + top };
|
||||
this.draggableController.clickToDrag(icon, clientPos);
|
||||
},
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { popper } = this;
|
||||
const appTheme = this.edgeless.std.get(ThemeProvider).app$.value;
|
||||
const basketIcon = appTheme === 'light' ? basketIconLight : basketIconDark;
|
||||
const glassBg =
|
||||
appTheme === 'light' ? 'rgba(255,255,255,0.5)' : 'rgba(74, 74, 74, 0.6)';
|
||||
|
||||
const { cancelled, dragOut, draggingElement } =
|
||||
this.draggableController?.states || {};
|
||||
|
||||
const active = popper || draggingElement;
|
||||
|
||||
return html`<edgeless-toolbar-button
|
||||
class="edgeless-mindmap-button"
|
||||
?withHover=${true}
|
||||
.tooltip=${popper ? '' : 'Others'}
|
||||
.tooltipOffset=${4}
|
||||
@click=${this._toggleMenu}
|
||||
style="width: 100%; height: 100%; display: inline-block"
|
||||
>
|
||||
<div class="partial-clip">
|
||||
<div class="basket-wrapper ${active ? 'active' : ''}">
|
||||
${repeat(
|
||||
this.draggableTools,
|
||||
t => t.name,
|
||||
tool => {
|
||||
const isBeingDragged = draggingElement?.data.name === tool.name;
|
||||
const variables = toolConfig2StyleObj(tool.config);
|
||||
|
||||
const nextStyle = styleMap({
|
||||
...variables,
|
||||
});
|
||||
const currentStyle = styleMap({
|
||||
...variables,
|
||||
opacity: isBeingDragged ? 0 : 1,
|
||||
pointerEvents: draggingElement ? 'none' : 'auto',
|
||||
});
|
||||
|
||||
return html`${isBeingDragged
|
||||
? html`<div
|
||||
class=${classMap({
|
||||
'basket-tool-item': true,
|
||||
next: true,
|
||||
coming: !!dragOut && !cancelled,
|
||||
})}
|
||||
style=${nextStyle}
|
||||
>
|
||||
${tool.icon}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div
|
||||
style=${currentStyle}
|
||||
@mousedown=${(e: MouseEvent) =>
|
||||
this.draggableController.onMouseDown(e, {
|
||||
data: tool,
|
||||
preview: tool.icon,
|
||||
standardWidth: tool.standardWidth,
|
||||
})}
|
||||
@touchstart=${(e: TouchEvent) =>
|
||||
this.draggableController.onTouchStart(e, {
|
||||
data: tool,
|
||||
preview: tool.icon,
|
||||
standardWidth: tool.standardWidth,
|
||||
})}
|
||||
class="basket-tool-item current ${tool.name}"
|
||||
>
|
||||
${tool.icon}
|
||||
</div>`;
|
||||
}
|
||||
)}
|
||||
|
||||
<div class="basket">
|
||||
<div
|
||||
class="glass ${this.enableBlur ? 'enabled' : ''}"
|
||||
style="background: ${glassBg}"
|
||||
></div>
|
||||
${basketIcon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg width="0" height="0" style="opacity: 0; pointer-events: none">
|
||||
<defs>
|
||||
<mask id="mindmap-basket-body-mask">
|
||||
<rect
|
||||
x="2"
|
||||
width="71.8"
|
||||
y="2"
|
||||
height="15"
|
||||
rx="1.5"
|
||||
ry="1.5"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
width="32"
|
||||
height="6"
|
||||
x="22"
|
||||
y="5.9"
|
||||
fill="black"
|
||||
rx="3"
|
||||
ry="3"
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
</svg>
|
||||
</edgeless-toolbar-button>`;
|
||||
}
|
||||
|
||||
override updated(_changedProperties: Map<PropertyKey, unknown>) {
|
||||
const controllerRequiredProps = ['edgeless', 'toolbarContainer'] as const;
|
||||
if (
|
||||
controllerRequiredProps.some(p => _changedProperties.has(p)) &&
|
||||
!this.draggableController
|
||||
) {
|
||||
this.initDragController();
|
||||
}
|
||||
}
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor enableBlur = true;
|
||||
|
||||
@query('.basket-tool-item.mindmap')
|
||||
accessor mindmapElement!: HTMLElement;
|
||||
|
||||
@state()
|
||||
accessor readyToDrop = false;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-mindmap-tool-button': EdgelessMindmapToolButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { openFileOrFiles } from '@blocksuite/affine-shared/utils';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type { Bound } from '@blocksuite/global/utils';
|
||||
import c from 'simple-xml-to-json';
|
||||
|
||||
type MindMapNode = {
|
||||
children: MindMapNode[];
|
||||
text: string;
|
||||
xywh?: string;
|
||||
title?: string;
|
||||
layoutType?: 'left' | 'right';
|
||||
};
|
||||
|
||||
export async function importMindmap(bound: Bound): Promise<MindMapNode> {
|
||||
const file = await openFileOrFiles({
|
||||
acceptType: 'MindMap',
|
||||
});
|
||||
|
||||
if (!file) {
|
||||
throw new BlockSuiteError(ErrorCode.UserAbortError, 'Aborted by user');
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
if (file.name.endsWith('.mm')) {
|
||||
result = await parseMmFile(file);
|
||||
} else if (file.name.endsWith('.opml') || file.name.endsWith('.xml')) {
|
||||
result = await parseOPMLFile(file);
|
||||
} else {
|
||||
throw new BlockSuiteError(ErrorCode.ParsingError, 'Unsupported file type');
|
||||
}
|
||||
|
||||
if (result) {
|
||||
result.xywh = bound.serialize();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function readAsText(file: File) {
|
||||
return file.text();
|
||||
}
|
||||
|
||||
type RawMmNode = {
|
||||
node?: {
|
||||
TEXT: string;
|
||||
POSITION: 'left' | 'right';
|
||||
children?: RawMmNode[];
|
||||
};
|
||||
};
|
||||
|
||||
async function parseMmFile(file: File): Promise<MindMapNode> {
|
||||
const content = await readAsText(file);
|
||||
|
||||
try {
|
||||
const parsed = c.convertXML(content);
|
||||
const map = parsed.map.children[0];
|
||||
|
||||
const traverse = (node: RawMmNode): MindMapNode | null => {
|
||||
if (!node.node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return node.node.POSITION
|
||||
? {
|
||||
layoutType: node.node.POSITION,
|
||||
text: node.node.TEXT ?? 'MINDMAP',
|
||||
children:
|
||||
(node.node.children
|
||||
?.map(traverse)
|
||||
.filter(node => node) as MindMapNode[]) ?? [],
|
||||
}
|
||||
: {
|
||||
text: node.node.TEXT ?? 'MINDMAP',
|
||||
children:
|
||||
(node.node.children
|
||||
?.map(traverse)
|
||||
.filter(node => node) as MindMapNode[]) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
const result = traverse(map);
|
||||
|
||||
if (!result) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ParsingError,
|
||||
'Failed to parse mm file'
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ParsingError,
|
||||
'Failed to parse mm file'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type RawOPMLOutline = {
|
||||
outline: {
|
||||
text: string;
|
||||
children: RawOPMLOutline[];
|
||||
};
|
||||
};
|
||||
|
||||
async function parseOPMLFile(file: File): Promise<MindMapNode> {
|
||||
const content = await readAsText(file);
|
||||
|
||||
try {
|
||||
const parsed = c.convertXML(content);
|
||||
const outline = parsed.opml?.children[1].body?.children?.[0];
|
||||
|
||||
const traverse = (node: RawOPMLOutline): MindMapNode | null => {
|
||||
if (!node.outline?.text && !node.outline?.children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
text: node.outline?.text ?? 'MINDMAP',
|
||||
children: node.outline.children
|
||||
? (node.outline.children.map(traverse) as MindMapNode[])
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
const result = traverse(outline);
|
||||
|
||||
if (!result) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ParsingError,
|
||||
'Failed to parse OPML file'
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ParsingError,
|
||||
'Failed to parse OPML file'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Constructor } from '@blocksuite/global/utils';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import {
|
||||
// eslint-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,174 @@
|
||||
import type { ColorScheme } from '@blocksuite/affine-model';
|
||||
import type {
|
||||
GfxToolsFullOption,
|
||||
GfxToolsFullOptionValue,
|
||||
ToolController,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
type Constructor,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
type DisposableClass,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
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 type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
|
||||
import { createPopper, type MenuPopper } from '../common/create-popper.js';
|
||||
import {
|
||||
edgelessToolbarContext,
|
||||
type EdgelessToolbarSlots,
|
||||
edgelessToolbarSlotsContext,
|
||||
edgelessToolbarThemeContext,
|
||||
} from '../context.js';
|
||||
import type { EdgelessToolbarWidget } from '../edgeless-toolbar.js';
|
||||
|
||||
type ValueOf<T> = T[keyof T];
|
||||
|
||||
export declare abstract class EdgelessToolbarToolClass extends DisposableClass {
|
||||
active: boolean;
|
||||
|
||||
createPopper: typeof createPopper;
|
||||
|
||||
edgeless: EdgelessRootBlockComponent;
|
||||
|
||||
edgelessTool: GfxToolsFullOptionValue;
|
||||
|
||||
enableActiveBackground?: boolean;
|
||||
|
||||
popper: MenuPopper<HTMLElement> | null;
|
||||
|
||||
setEdgelessTool: ToolController['setTool'];
|
||||
|
||||
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;
|
||||
const activeType = this.edgelessTool?.type;
|
||||
|
||||
return activeType
|
||||
? Array.isArray(type)
|
||||
? type.includes(activeType)
|
||||
: activeType === type
|
||||
: false;
|
||||
}
|
||||
|
||||
get setEdgelessTool() {
|
||||
return (...args: Parameters<ToolController['setTool']>) => {
|
||||
this.edgeless.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.edgeless.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!: EdgelessRootBlockComponent;
|
||||
|
||||
@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>;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Constructor } from '@blocksuite/global/utils';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import {
|
||||
// eslint-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,23 @@
|
||||
import { svg } from 'lit';
|
||||
|
||||
export const toShapeNotToAdapt = svg`<svg width="44" height="5" viewBox="0 0 44 5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M43.9013 1.45752V1.94112H42.5034V1.45752H43.9013ZM42.8208 0.901367H43.4646V3.06551C43.4646 3.12496 43.4737 3.1713 43.4918 3.20455C43.5099 3.23679 43.5351 3.25946 43.5674 3.27256C43.6006 3.28565 43.6389 3.2922 43.6822 3.2922C43.7124 3.2922 43.7427 3.28968 43.7729 3.28465C43.8031 3.2786 43.8263 3.27407 43.8424 3.27105L43.9437 3.75012C43.9114 3.76019 43.8661 3.77178 43.8076 3.78488C43.7492 3.79898 43.6782 3.80755 43.5946 3.81057C43.4394 3.81662 43.3034 3.79596 43.1865 3.74861C43.0706 3.70126 42.9805 3.62771 42.916 3.52796C42.8515 3.42822 42.8198 3.30228 42.8208 3.15014V0.901367Z" fill="currentColor"/>
|
||||
<path d="M39.9741 4.64977V1.45796H40.6089V1.84787H40.6376C40.6658 1.7854 40.7066 1.72193 40.76 1.65745C40.8144 1.59196 40.8849 1.53755 40.9716 1.49423C41.0592 1.4499 41.168 1.42773 41.298 1.42773C41.4673 1.42773 41.6234 1.47207 41.7665 1.56073C41.9096 1.64838 42.0239 1.78087 42.1096 1.95819C42.1952 2.13451 42.238 2.35566 42.238 2.62164C42.238 2.88057 42.1962 3.0992 42.1126 3.27753C42.03 3.45486 41.9171 3.58936 41.774 3.68104C41.632 3.77172 41.4728 3.81706 41.2965 3.81706C41.1716 3.81706 41.0653 3.79641 40.9776 3.7551C40.891 3.71379 40.8199 3.6619 40.7645 3.59944C40.7091 3.53596 40.6668 3.47199 40.6376 3.4075H40.6179V4.64977H39.9741ZM40.6043 2.61862C40.6043 2.75665 40.6235 2.87705 40.6618 2.97981C40.7 3.08258 40.7555 3.16268 40.828 3.22011C40.9005 3.27653 40.9887 3.30474 41.0925 3.30474C41.1972 3.30474 41.2859 3.27602 41.3584 3.21859C41.431 3.16016 41.4859 3.07956 41.5232 2.97679C41.5615 2.87302 41.5806 2.75363 41.5806 2.61862C41.5806 2.48462 41.562 2.36674 41.5247 2.26498C41.4874 2.16322 41.4325 2.08363 41.36 2.0262C41.2874 1.96877 41.1983 1.94006 41.0925 1.94006C40.9877 1.94006 40.899 1.96776 40.8265 2.02318C40.7549 2.07859 40.7 2.15718 40.6618 2.25894C40.6235 2.36069 40.6043 2.48059 40.6043 2.61862Z" fill="currentColor"/>
|
||||
<path d="M38.1667 3.8231C38.0186 3.8231 37.8867 3.79741 37.7708 3.74603C37.6549 3.69364 37.5632 3.61656 37.4957 3.5148C37.4292 3.41204 37.396 3.28408 37.396 3.13094C37.396 3.00198 37.4197 2.89367 37.467 2.80602C37.5144 2.71836 37.5789 2.64784 37.6605 2.59444C37.7421 2.54104 37.8348 2.50074 37.9385 2.47354C38.0433 2.44633 38.1531 2.42719 38.268 2.41611C38.403 2.402 38.5118 2.38891 38.5944 2.37681C38.6771 2.36372 38.737 2.34457 38.7743 2.31939C38.8116 2.2942 38.8302 2.25692 38.8302 2.20755V2.19848C38.8302 2.10277 38.8 2.02872 38.7395 1.97633C38.6801 1.92394 38.5954 1.89774 38.4856 1.89774C38.3698 1.89774 38.2776 1.92343 38.2091 1.97482C38.1406 2.02519 38.0952 2.08867 38.073 2.16524L37.4776 2.11688C37.5078 1.97582 37.5673 1.85391 37.6559 1.75115C37.7446 1.64737 37.8589 1.56778 37.999 1.51237C38.14 1.45594 38.3033 1.42773 38.4886 1.42773C38.6176 1.42773 38.741 1.44285 38.8589 1.47307C38.9778 1.5033 39.0831 1.55015 39.1748 1.61362C39.2675 1.67709 39.3405 1.7587 39.3939 1.85845C39.4473 1.95718 39.474 2.07557 39.474 2.2136V3.77928H38.8634V3.45738H38.8453C38.808 3.52992 38.7582 3.59389 38.6957 3.64931C38.6332 3.70371 38.5582 3.74653 38.4705 3.77777C38.3829 3.80799 38.2816 3.8231 38.1667 3.8231ZM38.3511 3.37879C38.4458 3.37879 38.5295 3.36015 38.602 3.32287C38.6745 3.28459 38.7315 3.2332 38.7728 3.16872C38.8141 3.10424 38.8347 3.0312 38.8347 2.94959V2.70325C38.8146 2.71635 38.7869 2.72844 38.7516 2.73952C38.7174 2.7496 38.6786 2.75917 38.6352 2.76823C38.5919 2.7763 38.5486 2.78385 38.5053 2.7909C38.4619 2.79695 38.4227 2.80249 38.3874 2.80753C38.3118 2.81861 38.2458 2.83624 38.1894 2.86042C38.133 2.8846 38.0892 2.91735 38.0579 2.95866C38.0267 2.99896 38.0111 3.04933 38.0111 3.10978C38.0111 3.19744 38.0428 3.26444 38.1063 3.31078C38.1708 3.35612 38.2524 3.37879 38.3511 3.37879Z" fill="currentColor"/>
|
||||
<path d="M35.6544 3.81647C35.4781 3.81647 35.3184 3.77113 35.1753 3.68045C35.0333 3.58877 34.9204 3.45426 34.8368 3.27694C34.7542 3.09861 34.7129 2.87998 34.7129 2.62105C34.7129 2.35506 34.7557 2.13391 34.8413 1.9576C34.927 1.78028 35.0408 1.64779 35.1829 1.56013C35.326 1.47147 35.4826 1.42714 35.6529 1.42714C35.7829 1.42714 35.8912 1.44931 35.9778 1.49364C36.0655 1.53696 36.136 1.59137 36.1894 1.65685C36.2438 1.72134 36.2851 1.78481 36.3133 1.84728H36.333V0.683594H36.9753V3.77868H36.3405V3.40691H36.3133C36.2831 3.47139 36.2403 3.53537 36.1849 3.59884C36.1305 3.66131 36.0594 3.7132 35.9718 3.7545C35.8851 3.79581 35.7793 3.81647 35.6544 3.81647ZM35.8584 3.30414C35.9622 3.30414 36.0499 3.27593 36.1214 3.21951C36.1939 3.16208 36.2494 3.08199 36.2876 2.97922C36.3269 2.87645 36.3466 2.75605 36.3466 2.61803C36.3466 2.48 36.3274 2.3601 36.2891 2.25834C36.2509 2.15658 36.1955 2.078 36.1229 2.02258C36.0504 1.96717 35.9622 1.93946 35.8584 1.93946C35.7526 1.93946 35.6635 1.96818 35.5909 2.02561C35.5184 2.08303 35.4635 2.16263 35.4262 2.26439C35.3889 2.36615 35.3703 2.48403 35.3703 2.61803C35.3703 2.75303 35.3889 2.87242 35.4262 2.9762C35.4645 3.07896 35.5194 3.15957 35.5909 3.218C35.6635 3.27543 35.7526 3.30414 35.8584 3.30414Z" fill="currentColor"/>
|
||||
<path d="M32.9929 3.82213C32.8448 3.82213 32.7128 3.79644 32.597 3.74505C32.4811 3.69266 32.3894 3.61559 32.3219 3.51383C32.2554 3.41106 32.2222 3.28311 32.2222 3.12996C32.2222 3.001 32.2458 2.89269 32.2932 2.80504C32.3406 2.71739 32.405 2.64686 32.4866 2.59346C32.5682 2.54006 32.6609 2.49976 32.7647 2.47256C32.8695 2.44536 32.9793 2.42621 33.0942 2.41513C33.2292 2.40103 33.338 2.38793 33.4206 2.37584C33.5032 2.36274 33.5632 2.3436 33.6005 2.31841C33.6377 2.29322 33.6564 2.25594 33.6564 2.20658V2.19751C33.6564 2.10179 33.6261 2.02774 33.5657 1.97535C33.5062 1.92296 33.4216 1.89676 33.3118 1.89676C33.1959 1.89676 33.1037 1.92246 33.0352 1.97384C32.9667 2.02422 32.9214 2.08769 32.8992 2.16426L32.3038 2.1159C32.334 1.97485 32.3934 1.85294 32.4821 1.75017C32.5708 1.6464 32.6851 1.5668 32.8252 1.51139C32.9662 1.45497 33.1294 1.42676 33.3148 1.42676C33.4438 1.42676 33.5672 1.44187 33.6851 1.4721C33.804 1.50232 33.9093 1.54917 34.0009 1.61264C34.0936 1.67612 34.1667 1.75773 34.2201 1.85747C34.2735 1.95621 34.3002 2.07459 34.3002 2.21262V3.7783H33.6896V3.4564H33.6715C33.6342 3.52894 33.5843 3.59292 33.5219 3.64833C33.4594 3.70274 33.3843 3.74556 33.2967 3.77679C33.209 3.80702 33.1078 3.82213 32.9929 3.82213ZM33.1773 3.37781C33.272 3.37781 33.3556 3.35917 33.4282 3.3219C33.5007 3.28361 33.5576 3.23223 33.5989 3.16775C33.6402 3.10327 33.6609 3.03022 33.6609 2.94861V2.70227C33.6408 2.71537 33.613 2.72746 33.5778 2.73854C33.5435 2.74862 33.5047 2.75819 33.4614 2.76726C33.4181 2.77532 33.3748 2.78287 33.3314 2.78993C33.2881 2.79597 33.2488 2.80151 33.2136 2.80655C33.138 2.81763 33.072 2.83527 33.0156 2.85945C32.9592 2.88363 32.9153 2.91637 32.8841 2.95768C32.8529 2.99798 32.8373 3.04836 32.8373 3.10881C32.8373 3.19646 32.869 3.26346 32.9325 3.30981C32.9969 3.35514 33.0786 3.37781 33.1773 3.37781Z" fill="currentColor"/>
|
||||
<path d="M29.7856 3.82364C29.5508 3.82364 29.3478 3.77377 29.1765 3.67402C29.0063 3.57327 28.8748 3.43323 28.7821 3.25389C28.6894 3.07354 28.6431 2.86448 28.6431 2.62671C28.6431 2.38692 28.6894 2.17736 28.7821 1.99802C28.8748 1.81767 29.0063 1.67763 29.1765 1.57789C29.3478 1.47713 29.5508 1.42676 29.7856 1.42676C30.0203 1.42676 30.2229 1.47713 30.3931 1.57789C30.5644 1.67763 30.6964 1.81767 30.7891 1.99802C30.8818 2.17736 30.9281 2.38692 30.9281 2.62671C30.9281 2.86448 30.8818 3.07354 30.7891 3.25389C30.6964 3.43323 30.5644 3.57327 30.3931 3.67402C30.2229 3.77377 30.0203 3.82364 29.7856 3.82364ZM29.7886 3.32492C29.8954 3.32492 29.9846 3.29469 30.0561 3.23424C30.1276 3.17278 30.1815 3.08916 30.2178 2.98337C30.2551 2.87758 30.2737 2.75718 30.2737 2.62218C30.2737 2.48717 30.2551 2.36677 30.2178 2.26098C30.1815 2.15519 30.1276 2.07157 30.0561 2.01011C29.9846 1.94865 29.8954 1.91792 29.7886 1.91792C29.6808 1.91792 29.5901 1.94865 29.5166 2.01011C29.444 2.07157 29.3891 2.15519 29.3519 2.26098C29.3156 2.36677 29.2974 2.48717 29.2974 2.62218C29.2974 2.75718 29.3156 2.87758 29.3519 2.98337C29.3891 3.08916 29.444 3.17278 29.5166 3.23424C29.5901 3.29469 29.6808 3.32492 29.7886 3.32492Z" fill="currentColor"/>
|
||||
<path d="M28.3413 1.45752V1.94112H26.9434V1.45752H28.3413ZM27.2607 0.901367H27.9045V3.06551C27.9045 3.12496 27.9136 3.1713 27.9317 3.20455C27.9499 3.23679 27.9751 3.25946 28.0073 3.27256C28.0405 3.28565 28.0788 3.2922 28.1222 3.2922C28.1524 3.2922 28.1826 3.28968 28.2128 3.28465C28.2431 3.2786 28.2662 3.27407 28.2823 3.27105L28.3836 3.75012C28.3514 3.76019 28.306 3.77178 28.2476 3.78488C28.1892 3.79898 28.1181 3.80755 28.0345 3.81057C27.8793 3.81662 27.7433 3.79596 27.6265 3.74861C27.5106 3.70126 27.4204 3.62771 27.3559 3.52796C27.2915 3.42822 27.2597 3.30228 27.2607 3.15014V0.901367Z" fill="currentColor"/>
|
||||
<path d="M25.7007 1.45752V1.94112H24.3027V1.45752H25.7007ZM24.6201 0.901367H25.2639V3.06551C25.2639 3.12496 25.273 3.1713 25.2911 3.20455C25.3092 3.23679 25.3344 3.25946 25.3667 3.27256C25.3999 3.28565 25.4382 3.2922 25.4815 3.2922C25.5118 3.2922 25.542 3.28968 25.5722 3.28465C25.6024 3.2786 25.6256 3.27407 25.6417 3.27105L25.743 3.75012C25.7107 3.76019 25.6654 3.77178 25.607 3.78488C25.5485 3.79898 25.4775 3.80755 25.3939 3.81057C25.2387 3.81662 25.1027 3.79596 24.9858 3.74861C24.87 3.70126 24.7798 3.62771 24.7153 3.52796C24.6508 3.42822 24.6191 3.30228 24.6201 3.15014V0.901367Z" fill="currentColor"/>
|
||||
<path d="M22.9062 3.82462C22.6714 3.82462 22.4684 3.77474 22.2972 3.675C22.1269 3.57425 21.9954 3.4342 21.9027 3.25487C21.81 3.07452 21.7637 2.86546 21.7637 2.62769C21.7637 2.3879 21.81 2.17833 21.9027 1.999C21.9954 1.81865 22.1269 1.67861 22.2972 1.57886C22.4684 1.47811 22.6714 1.42773 22.9062 1.42773C23.1409 1.42773 23.3435 1.47811 23.5137 1.57886C23.685 1.67861 23.817 1.81865 23.9097 1.999C24.0024 2.17833 24.0487 2.3879 24.0487 2.62769C24.0487 2.86546 24.0024 3.07452 23.9097 3.25487C23.817 3.4342 23.685 3.57425 23.5137 3.675C23.3435 3.77474 23.1409 3.82462 22.9062 3.82462ZM22.9092 3.3259C23.016 3.3259 23.1052 3.29567 23.1767 3.23522C23.2482 3.17376 23.3021 3.09014 23.3384 2.98435C23.3757 2.87856 23.3943 2.75816 23.3943 2.62315C23.3943 2.48815 23.3757 2.36775 23.3384 2.26196C23.3021 2.15617 23.2482 2.07254 23.1767 2.01109C23.1052 1.94963 23.016 1.9189 22.9092 1.9189C22.8014 1.9189 22.7107 1.94963 22.6372 2.01109C22.5646 2.07254 22.5097 2.15617 22.4725 2.26196C22.4362 2.36775 22.4181 2.48815 22.4181 2.62315C22.4181 2.75816 22.4362 2.87856 22.4725 2.98435C22.5097 3.09014 22.5646 3.17376 22.6372 3.23522C22.7107 3.29567 22.8014 3.3259 22.9092 3.3259Z" fill="currentColor"/>
|
||||
<path d="M19.8538 2.43727V3.77928H19.21V1.45796H19.8235V1.86752H19.8507C19.9021 1.73251 19.9883 1.62571 20.1092 1.54712C20.2301 1.46753 20.3767 1.42773 20.549 1.42773C20.7102 1.42773 20.8507 1.463 20.9706 1.53352C21.0905 1.60405 21.1837 1.7048 21.2502 1.83578C21.3167 1.96575 21.3499 2.12091 21.3499 2.30125V3.77928H20.7061V2.41611C20.7071 2.27405 20.6709 2.16322 20.5973 2.08363C20.5238 2.00303 20.4225 1.96273 20.2935 1.96273C20.2069 1.96273 20.1303 1.98136 20.0638 2.01864C19.9983 2.05592 19.947 2.11033 19.9097 2.18186C19.8734 2.25239 19.8548 2.33752 19.8538 2.43727Z" fill="currentColor"/>
|
||||
<path d="M16.7385 3.82364C16.4997 3.82364 16.2942 3.77528 16.1219 3.67856C15.9506 3.58083 15.8186 3.4428 15.726 3.26447C15.6333 3.08513 15.5869 2.87305 15.5869 2.62822C15.5869 2.38944 15.6333 2.17988 15.726 1.99953C15.8186 1.81919 15.9491 1.67864 16.1174 1.57789C16.2866 1.47713 16.4851 1.42676 16.7128 1.42676C16.866 1.42676 17.0085 1.45144 17.1405 1.50081C17.2735 1.54917 17.3894 1.62222 17.4881 1.71995C17.5878 1.81767 17.6654 1.94059 17.7208 2.0887C17.7762 2.23579 17.804 2.40808 17.804 2.60555V2.78237H15.8438V2.38339H17.1979C17.1979 2.2907 17.1778 2.20859 17.1375 2.13706C17.0972 2.06552 17.0413 2.00961 16.9697 1.96931C16.8992 1.928 16.8171 1.90734 16.7234 1.90734C16.6257 1.90734 16.539 1.93001 16.4635 1.97535C16.3889 2.01968 16.3305 2.07963 16.2881 2.15519C16.2458 2.22975 16.2242 2.31287 16.2232 2.40455V2.78388C16.2232 2.89874 16.2443 2.99798 16.2866 3.0816C16.33 3.16523 16.3909 3.22971 16.4695 3.27505C16.5481 3.32038 16.6413 3.34305 16.7491 3.34305C16.8206 3.34305 16.8861 3.33298 16.9455 3.31283C17.005 3.29268 17.0559 3.26245 17.0982 3.22215C17.1405 3.18185 17.1727 3.13248 17.1949 3.07405L17.7904 3.11334C17.7601 3.25641 17.6982 3.38134 17.6045 3.48814C17.5118 3.59393 17.3919 3.67654 17.2448 3.73599C17.0987 3.79442 16.9299 3.82364 16.7385 3.82364Z" fill="currentColor"/>
|
||||
<path d="M12.9878 4.64977V1.45796H13.6225V1.84787H13.6512C13.6795 1.7854 13.7203 1.72193 13.7737 1.65745C13.8281 1.59196 13.8986 1.53755 13.9852 1.49423C14.0729 1.4499 14.1817 1.42773 14.3117 1.42773C14.4809 1.42773 14.6371 1.47207 14.7802 1.56073C14.9232 1.64838 15.0376 1.78087 15.1232 1.95819C15.2089 2.13451 15.2517 2.35566 15.2517 2.62164C15.2517 2.88057 15.2099 3.0992 15.1262 3.27753C15.0436 3.45486 14.9308 3.58936 14.7877 3.68104C14.6457 3.77172 14.4865 3.81706 14.3102 3.81706C14.1852 3.81706 14.0789 3.79641 13.9913 3.7551C13.9046 3.71379 13.8336 3.6619 13.7782 3.59944C13.7228 3.53596 13.6805 3.47199 13.6512 3.4075H13.6316V4.64977H12.9878ZM13.618 2.61862C13.618 2.75665 13.6371 2.87705 13.6754 2.97981C13.7137 3.08258 13.7691 3.16268 13.8417 3.22011C13.9142 3.27653 14.0024 3.30474 14.1061 3.30474C14.2109 3.30474 14.2996 3.27602 14.3721 3.21859C14.4447 3.16016 14.4996 3.07956 14.5368 2.97679C14.5751 2.87302 14.5943 2.75363 14.5943 2.61862C14.5943 2.48462 14.5756 2.36674 14.5384 2.26498C14.5011 2.16322 14.4462 2.08363 14.3736 2.0262C14.3011 1.96877 14.2119 1.94006 14.1061 1.94006C14.0014 1.94006 13.9127 1.96776 13.8402 2.02318C13.7686 2.07859 13.7137 2.15718 13.6754 2.25894C13.6371 2.36069 13.618 2.48059 13.618 2.61862Z" fill="currentColor"/>
|
||||
<path d="M11.1814 3.82213C11.0333 3.82213 10.9013 3.79644 10.7854 3.74505C10.6696 3.69266 10.5779 3.61559 10.5104 3.51383C10.4439 3.41106 10.4106 3.28311 10.4106 3.12996C10.4106 3.001 10.4343 2.89269 10.4817 2.80504C10.529 2.71739 10.5935 2.64686 10.6751 2.59346C10.7567 2.54006 10.8494 2.49976 10.9532 2.47256C11.058 2.44536 11.1678 2.42621 11.2827 2.41513C11.4177 2.40103 11.5265 2.38793 11.6091 2.37584C11.6917 2.36274 11.7516 2.3436 11.7889 2.31841C11.8262 2.29322 11.8448 2.25594 11.8448 2.20658V2.19751C11.8448 2.10179 11.8146 2.02774 11.7542 1.97535C11.6947 1.92296 11.6101 1.89676 11.5003 1.89676C11.3844 1.89676 11.2922 1.92246 11.2237 1.97384C11.1552 2.02422 11.1099 2.08769 11.0877 2.16426L10.4923 2.1159C10.5225 1.97485 10.5819 1.85294 10.6706 1.75017C10.7592 1.6464 10.8736 1.5668 11.0136 1.51139C11.1547 1.45497 11.3179 1.42676 11.5033 1.42676C11.6323 1.42676 11.7557 1.44187 11.8736 1.4721C11.9924 1.50232 12.0977 1.54917 12.1894 1.61264C12.2821 1.67612 12.3552 1.75773 12.4085 1.85747C12.4619 1.95621 12.4886 2.07459 12.4886 2.21262V3.7783H11.8781V3.4564H11.86C11.8227 3.52894 11.7728 3.59292 11.7103 3.64833C11.6479 3.70274 11.5728 3.74556 11.4852 3.77679C11.3975 3.80702 11.2963 3.82213 11.1814 3.82213ZM11.3658 3.37781C11.4605 3.37781 11.5441 3.35917 11.6166 3.3219C11.6892 3.28361 11.7461 3.23223 11.7874 3.16775C11.8287 3.10327 11.8494 3.03022 11.8494 2.94861V2.70227C11.8292 2.71537 11.8015 2.72746 11.7663 2.73854C11.732 2.74862 11.6932 2.75819 11.6499 2.76726C11.6066 2.77532 11.5632 2.78287 11.5199 2.78993C11.4766 2.79597 11.4373 2.80151 11.402 2.80655C11.3265 2.81763 11.2605 2.83527 11.2041 2.85945C11.1476 2.88363 11.1038 2.91637 11.0726 2.95768C11.0413 2.99798 11.0257 3.04836 11.0257 3.10881C11.0257 3.19646 11.0575 3.26346 11.1209 3.30981C11.1854 3.35514 11.267 3.37781 11.3658 3.37781Z" fill="currentColor"/>
|
||||
<path d="M8.50757 2.43667V3.77868H7.86377V0.683594H8.48944V1.86692H8.51664C8.56903 1.7299 8.65366 1.6226 8.77053 1.54502C8.88741 1.46643 9.034 1.42714 9.21032 1.42714C9.37152 1.42714 9.51207 1.4624 9.63196 1.53293C9.75286 1.60245 9.84656 1.7027 9.91306 1.83367C9.98056 1.96364 10.0138 2.1193 10.0128 2.30066V3.77868H9.369V2.41551C9.37001 2.27245 9.33374 2.16112 9.26019 2.08152C9.18765 2.00193 9.08589 1.96213 8.95491 1.96213C8.86726 1.96213 8.78968 1.98077 8.72217 2.01805C8.65568 2.05533 8.60329 2.10973 8.565 2.18127C8.52772 2.25179 8.50858 2.33693 8.50757 2.43667Z" fill="currentColor"/>
|
||||
<path d="M7.40576 2.1199L6.81636 2.15617C6.80629 2.10579 6.78462 2.06045 6.75138 2.02015C6.71813 1.97885 6.6743 1.9461 6.61989 1.92192C6.5665 1.89673 6.50252 1.88414 6.42796 1.88414C6.32822 1.88414 6.24409 1.9053 6.17558 1.94761C6.10707 1.98892 6.07281 2.04433 6.07281 2.11385C6.07281 2.16927 6.09498 2.21612 6.13931 2.2544C6.18364 2.29269 6.25971 2.32342 6.36751 2.34659L6.78765 2.43122C7.01333 2.47757 7.18159 2.55212 7.29241 2.65489C7.40324 2.75766 7.45865 2.89266 7.45865 3.05991C7.45865 3.21205 7.41382 3.34554 7.32415 3.4604C7.23549 3.57526 7.11358 3.66492 6.95842 3.72941C6.80427 3.79288 6.62644 3.82462 6.42494 3.82462C6.11765 3.82462 5.87282 3.76064 5.69046 3.63268C5.50911 3.50372 5.40282 3.32841 5.37158 3.10676L6.00481 3.07351C6.02395 3.16721 6.07029 3.23875 6.14384 3.28811C6.21739 3.33647 6.31159 3.36065 6.42645 3.36065C6.53929 3.36065 6.62997 3.33899 6.69848 3.29567C6.768 3.25134 6.80326 3.19441 6.80427 3.1249C6.80326 3.06646 6.77858 3.0186 6.73022 2.98132C6.68186 2.94304 6.6073 2.91382 6.50655 2.89367L6.10455 2.81357C5.87786 2.76823 5.7091 2.68965 5.59827 2.57781C5.48845 2.46598 5.43354 2.32342 5.43354 2.15012C5.43354 2.00101 5.47384 1.87255 5.55445 1.76475C5.63606 1.65694 5.75041 1.57382 5.89751 1.51539C6.04561 1.45695 6.2189 1.42773 6.41738 1.42773C6.71057 1.42773 6.94129 1.4897 7.10955 1.61362C7.27881 1.73755 7.37755 1.9063 7.40576 2.1199Z" fill="currentColor"/>
|
||||
<path d="M2.92866 3.82364C2.69391 3.82364 2.49089 3.77377 2.31961 3.67402C2.14934 3.57327 2.01786 3.43323 1.92517 3.25389C1.83248 3.07354 1.78613 2.86448 1.78613 2.62671C1.78613 2.38692 1.83248 2.17736 1.92517 1.99802C2.01786 1.81767 2.14934 1.67763 2.31961 1.57789C2.49089 1.47713 2.69391 1.42676 2.92866 1.42676C3.16341 1.42676 3.36592 1.47713 3.53619 1.57789C3.70747 1.67763 3.83945 1.81767 3.93214 1.99802C4.02483 2.17736 4.07118 2.38692 4.07118 2.62671C4.07118 2.86448 4.02483 3.07354 3.93214 3.25389C3.83945 3.43323 3.70747 3.57327 3.53619 3.67402C3.36592 3.77377 3.16341 3.82364 2.92866 3.82364ZM2.93168 3.32492C3.03848 3.32492 3.12764 3.29469 3.19917 3.23424C3.27071 3.17278 3.32461 3.08916 3.36088 2.98337C3.39816 2.87758 3.4168 2.75718 3.4168 2.62218C3.4168 2.48717 3.39816 2.36677 3.36088 2.26098C3.32461 2.15519 3.27071 2.07157 3.19917 2.01011C3.12764 1.94865 3.03848 1.91792 2.93168 1.91792C2.82387 1.91792 2.7332 1.94865 2.65965 2.01011C2.58711 2.07157 2.5322 2.15519 2.49492 2.26098C2.45865 2.36677 2.44051 2.48717 2.44051 2.62218C2.44051 2.75718 2.45865 2.87758 2.49492 2.98337C2.5322 3.08916 2.58711 3.17278 2.65965 3.23424C2.7332 3.29469 2.82387 3.32492 2.93168 3.32492Z" fill="currentColor"/>
|
||||
<path d="M1.48533 1.45752V1.94112H0.0874023L0.0874023 1.45752H1.48533ZM0.40477 0.901367H1.04857V3.06551C1.04857 3.12496 1.05764 3.1713 1.07578 3.20455C1.09391 3.23679 1.1191 3.25946 1.15134 3.27256C1.18459 3.28565 1.22287 3.2922 1.2662 3.2922C1.29642 3.2922 1.32665 3.28968 1.35687 3.28465C1.3871 3.2786 1.41027 3.27407 1.42639 3.27105L1.52765 3.75012C1.49541 3.76019 1.45007 3.77178 1.39163 3.78488C1.3332 3.79898 1.26217 3.80755 1.17854 3.81057C1.02339 3.81662 0.88737 3.79596 0.770499 3.74861C0.654634 3.70126 0.564461 3.62771 0.49998 3.52796C0.435499 3.42822 0.403763 3.30228 0.40477 3.15014V0.901367Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
`;
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
BulletedListIcon,
|
||||
CheckBoxIcon,
|
||||
CodeBlockIcon,
|
||||
DividerIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
Heading5Icon,
|
||||
Heading6Icon,
|
||||
NumberedListIcon,
|
||||
QuoteIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import type { NoteChildrenFlavour } from '../../../../../_common/utils/index.js';
|
||||
|
||||
export const BUTTON_GROUP_LENGTH = 10;
|
||||
|
||||
export type NoteMenuItem = {
|
||||
icon: TemplateResult<1>;
|
||||
tooltip: string;
|
||||
childFlavour: NoteChildrenFlavour;
|
||||
childType: string | null;
|
||||
};
|
||||
|
||||
const LIST_ITEMS = [
|
||||
{
|
||||
flavour: 'affine:list',
|
||||
type: 'bulleted',
|
||||
name: 'Bulleted List',
|
||||
description: 'A simple bulleted list.',
|
||||
icon: BulletedListIcon,
|
||||
tooltip: 'Drag/Click to insert Bulleted List',
|
||||
},
|
||||
{
|
||||
flavour: 'affine:list',
|
||||
type: 'numbered',
|
||||
name: 'Numbered List',
|
||||
description: 'A list with numbering.',
|
||||
icon: NumberedListIcon,
|
||||
tooltip: 'Drag/Click to insert Numbered List',
|
||||
},
|
||||
{
|
||||
flavour: 'affine:list',
|
||||
type: 'todo',
|
||||
name: 'To-do List',
|
||||
description: 'Track tasks with a to-do list.',
|
||||
icon: CheckBoxIcon,
|
||||
tooltip: 'Drag/Click to insert To-do List',
|
||||
},
|
||||
];
|
||||
|
||||
const TEXT_ITEMS = [
|
||||
{
|
||||
flavour: 'affine:paragraph',
|
||||
type: 'text',
|
||||
name: 'Text',
|
||||
description: 'Start typing with plain text.',
|
||||
icon: TextIcon,
|
||||
tooltip: 'Drag/Click to insert Text block',
|
||||
},
|
||||
{
|
||||
flavour: 'affine:paragraph',
|
||||
type: 'h1',
|
||||
name: 'Heading 1',
|
||||
description: 'Headings in the largest font.',
|
||||
icon: Heading1Icon,
|
||||
tooltip: 'Drag/Click to insert Heading 1',
|
||||
},
|
||||
{
|
||||
flavour: 'affine:paragraph',
|
||||
type: 'h2',
|
||||
name: 'Heading 2',
|
||||
description: 'Headings in the 2nd font size.',
|
||||
icon: Heading2Icon,
|
||||
tooltip: 'Drag/Click to insert Heading 2',
|
||||
},
|
||||
{
|
||||
flavour: 'affine:paragraph',
|
||||
type: 'h3',
|
||||
name: 'Heading 3',
|
||||
description: 'Headings in the 3rd font size.',
|
||||
icon: Heading3Icon,
|
||||
tooltip: 'Drag/Click to insert Heading 3',
|
||||
},
|
||||
{
|
||||
flavour: 'affine:paragraph',
|
||||
type: 'h4',
|
||||
name: 'Heading 4',
|
||||
description: 'Heading in the 4th font size.',
|
||||
icon: Heading4Icon,
|
||||
tooltip: 'Drag/Click to insert Heading 4',
|
||||
},
|
||||
{
|
||||
flavour: 'affine:paragraph',
|
||||
type: 'h5',
|
||||
name: 'Heading 5',
|
||||
description: 'Heading in the 5th font size.',
|
||||
icon: Heading5Icon,
|
||||
tooltip: 'Drag/Click to insert Heading 5',
|
||||
},
|
||||
{
|
||||
flavour: 'affine:paragraph',
|
||||
type: 'h6',
|
||||
name: 'Heading 6',
|
||||
description: 'Heading in the 6th font size.',
|
||||
icon: Heading6Icon,
|
||||
tooltip: 'Drag/Click to insert Heading 6',
|
||||
},
|
||||
{
|
||||
flavour: 'affine:code',
|
||||
type: 'code',
|
||||
name: 'Code Block',
|
||||
description: 'Capture a code snippet.',
|
||||
icon: CodeBlockIcon,
|
||||
tooltip: 'Drag/Click to insert Code Block',
|
||||
},
|
||||
{
|
||||
flavour: 'affine:paragraph',
|
||||
type: 'quote',
|
||||
name: 'Quote',
|
||||
description: 'Capture a quote.',
|
||||
icon: QuoteIcon,
|
||||
tooltip: 'Drag/Click to insert Quote',
|
||||
},
|
||||
{
|
||||
flavour: 'affine:divider',
|
||||
type: null,
|
||||
name: 'Divider',
|
||||
description: 'A visual divider.',
|
||||
icon: DividerIcon,
|
||||
tooltip: 'A visual divider',
|
||||
},
|
||||
];
|
||||
|
||||
// TODO: add image, bookmark, database blocks
|
||||
export const NOTE_MENU_ITEMS = TEXT_ITEMS.concat(LIST_ITEMS)
|
||||
.filter(item => item.name !== 'Divider')
|
||||
.map(item => {
|
||||
return {
|
||||
icon: item.icon,
|
||||
tooltip:
|
||||
item.type !== 'text'
|
||||
? item.tooltip.replace('Drag/Click to insert ', '')
|
||||
: 'Text',
|
||||
childFlavour: item.flavour as NoteChildrenFlavour,
|
||||
childType: item.type,
|
||||
} as NoteMenuItem;
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { AttachmentIcon, LinkIcon } from '@blocksuite/affine-components/icons';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import {
|
||||
getImageFilesFromLocal,
|
||||
type NoteChildrenFlavour,
|
||||
openFileOrFiles,
|
||||
} from '../../../../../_common/utils/index.js';
|
||||
import { ImageIcon } from '../../../../../image-block/styles.js';
|
||||
import type { NoteToolOption } from '../../../gfx-tool/note-tool.js';
|
||||
import { addAttachments, addImages } from '../../../utils/common.js';
|
||||
import { getTooltipWithShortcut } from '../../utils.js';
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
import { NOTE_MENU_ITEMS } from './note-menu-config.js';
|
||||
|
||||
export class EdgelessNoteMenu extends EdgelessToolbarToolMixin(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
z-index: -1;
|
||||
}
|
||||
.menu-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.button-group-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
fill: var(--affine-icon-color);
|
||||
}
|
||||
.button-group-container svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--affine-border-color);
|
||||
transform: scaleX(0.5);
|
||||
margin: 0 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
override type: GfxToolsFullOptionValue['type'] = 'affine:note';
|
||||
|
||||
private async _addImages() {
|
||||
this._imageLoading = true;
|
||||
const imageFiles = await getImageFilesFromLocal();
|
||||
const ids = await addImages(this.edgeless.std, imageFiles);
|
||||
this._imageLoading = false;
|
||||
this.edgeless.gfx.tool.setTool('default');
|
||||
this.edgeless.gfx.selection.set({ elements: ids });
|
||||
}
|
||||
|
||||
private _onHandleLinkButtonClick() {
|
||||
const { insertedLinkType } = this.edgeless.service.std.command.exec(
|
||||
'insertLinkByQuickSearch'
|
||||
);
|
||||
|
||||
insertedLinkType
|
||||
?.then(type => {
|
||||
const flavour = type?.flavour;
|
||||
if (!flavour) return;
|
||||
|
||||
this.edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'toolbar:general',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
type: flavour.split(':')[1],
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const tool = this.edgeless.gfx.tool.currentToolOption$.value;
|
||||
|
||||
if (tool?.type !== 'affine:note') return;
|
||||
this.childFlavour = tool.childFlavour;
|
||||
this.childType = tool.childType;
|
||||
this.tip = tool.tip;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { childType } = this;
|
||||
|
||||
return html`
|
||||
<edgeless-slide-menu>
|
||||
<div class="menu-content">
|
||||
<!-- add to edgeless -->
|
||||
<div class="button-group-container">
|
||||
<edgeless-tool-icon-button
|
||||
.activeMode=${'background'}
|
||||
.tooltip=${'Image'}
|
||||
@click=${this._addImages}
|
||||
.disabled=${this._imageLoading}
|
||||
>
|
||||
${ImageIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
|
||||
<edgeless-tool-icon-button
|
||||
.activeMode=${'background'}
|
||||
.tooltip=${getTooltipWithShortcut('Link', '@')}
|
||||
@click=${() => {
|
||||
this._onHandleLinkButtonClick();
|
||||
}}
|
||||
>
|
||||
${LinkIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
|
||||
<edgeless-tool-icon-button
|
||||
.activeMode=${'background'}
|
||||
.tooltip=${'File'}
|
||||
@click=${async () => {
|
||||
const file = await openFileOrFiles();
|
||||
if (!file) return;
|
||||
await addAttachments(this.edgeless.std, [file]);
|
||||
this.edgeless.gfx.tool.setTool('default');
|
||||
this.edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'toolbar:general',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'attachment',
|
||||
});
|
||||
}}
|
||||
>
|
||||
${AttachmentIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- add to note -->
|
||||
<div class="button-group-container">
|
||||
${repeat(
|
||||
NOTE_MENU_ITEMS,
|
||||
item => item.childFlavour,
|
||||
item => html`
|
||||
<edgeless-tool-icon-button
|
||||
.active=${childType === item.childType}
|
||||
.activeMode=${'background'}
|
||||
.tooltip=${item.tooltip}
|
||||
@click=${() =>
|
||||
this.onChange({
|
||||
childFlavour: item.childFlavour,
|
||||
childType: item.childType,
|
||||
tip: item.tooltip,
|
||||
})}
|
||||
>
|
||||
${item.icon}
|
||||
</edgeless-tool-icon-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</edgeless-slide-menu>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _imageLoading = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor childFlavour!: NoteChildrenFlavour;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor childType!: string | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onChange!: (
|
||||
props: Partial<{
|
||||
childFlavour: NoteToolOption['childFlavour'];
|
||||
childType: string | null;
|
||||
tip: string;
|
||||
}>
|
||||
) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor tip!: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-note-menu': EdgelessNoteMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
Heading1Icon,
|
||||
LinkIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import {
|
||||
EditPropsStore,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { SignalWatcher } from '@blocksuite/global/utils';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
|
||||
import type { NoteToolOption } from '../../../gfx-tool/note-tool.js';
|
||||
import { getTooltipWithShortcut } from '../../utils.js';
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
import { toShapeNotToAdapt } from './icon.js';
|
||||
|
||||
export class EdgelessNoteSeniorButton extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host,
|
||||
.edgeless-note-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
:host * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.note-root[data-app-theme='light'] {
|
||||
--paper-border-color: var(--affine-pure-white);
|
||||
--paper-foriegn-color: rgba(0, 0, 0, 0.1);
|
||||
--paper-shadow: 0px 2px 4px rgba(0, 0, 0, 0.25);
|
||||
--icon-card-bg: #fff;
|
||||
--icon-card-shadow: 0px 2px 4px rgba(0, 0, 0, 0.22),
|
||||
inset 0px -2px 1px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
.note-root[data-app-theme='dark'] {
|
||||
--paper-border-color: var(--affine-divider-color);
|
||||
--paper-foriegn-color: rgba(255, 255, 255, 0.12);
|
||||
--paper-shadow: 0px 2px 6px rgba(0, 0, 0, 0.8);
|
||||
--icon-card-bg: #343434;
|
||||
--icon-card-shadow: 0px 2px 4px rgba(0, 0, 0, 0.6),
|
||||
inset 0px -2px 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.note-root {
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
.paper {
|
||||
--y: 20px;
|
||||
--r: 4.42deg;
|
||||
width: 60px;
|
||||
height: 72px;
|
||||
background: var(--paper-bg);
|
||||
border: 1px solid var(--paper-border-color);
|
||||
position: absolute;
|
||||
transform: translateY(var(--y)) rotate(var(--r));
|
||||
color: var(--paper-foriegn-color);
|
||||
box-shadow: var(--paper-shadow);
|
||||
padding-top: 32px;
|
||||
padding-left: 3px;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.edgeless-toolbar-note-icon {
|
||||
position: absolute;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--affine-icon-secondary);
|
||||
background: var(--icon-card-bg);
|
||||
box-shadow: var(--icon-card-shadow);
|
||||
bottom: 12px;
|
||||
transition: transform 0.4s ease;
|
||||
transform: translateX(var(--x)) translateY(var(--y)) rotate(var(--r));
|
||||
}
|
||||
.edgeless-toolbar-note-icon.link {
|
||||
--x: -22px;
|
||||
--y: -5px;
|
||||
--r: -6deg;
|
||||
transform-origin: 0% 100%;
|
||||
}
|
||||
.edgeless-toolbar-note-icon.text {
|
||||
--r: 4deg;
|
||||
--x: 0px;
|
||||
--y: 0px;
|
||||
}
|
||||
.edgeless-toolbar-note-icon.heading {
|
||||
--x: 21px;
|
||||
--y: -7px;
|
||||
--r: 8deg;
|
||||
transform-origin: 0% 100%;
|
||||
}
|
||||
|
||||
.note-root:hover .paper {
|
||||
--y: 15px;
|
||||
}
|
||||
.note-root:hover .link {
|
||||
--x: -25px;
|
||||
--y: -5px;
|
||||
--r: -9.5deg;
|
||||
}
|
||||
.note-root:hover .text {
|
||||
--y: -10px;
|
||||
}
|
||||
.note-root:hover .heading {
|
||||
--x: 23px;
|
||||
--y: -8px;
|
||||
--r: 15deg;
|
||||
}
|
||||
`;
|
||||
|
||||
private _noteBg$ = computed(() => {
|
||||
return this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value['affine:note']
|
||||
.background
|
||||
);
|
||||
});
|
||||
|
||||
private _states = ['childFlavour', 'childType', 'tip'] as const;
|
||||
|
||||
override enableActiveBackground = true;
|
||||
|
||||
override type = 'affine:note' as const;
|
||||
|
||||
private _toggleNoteMenu() {
|
||||
if (this.tryDisposePopper()) return;
|
||||
|
||||
const { edgeless, childFlavour, childType, tip } = this;
|
||||
|
||||
this.setEdgelessTool({
|
||||
type: 'affine:note',
|
||||
childFlavour,
|
||||
childType,
|
||||
tip,
|
||||
});
|
||||
const menu = this.createPopper('edgeless-note-menu', this);
|
||||
|
||||
Object.assign(menu.element, {
|
||||
edgeless,
|
||||
childFlavour,
|
||||
childType,
|
||||
tip,
|
||||
onChange: (
|
||||
props: Partial<{
|
||||
childFlavour: NoteToolOption['childFlavour'];
|
||||
childType: string | null;
|
||||
tip: string;
|
||||
}>
|
||||
) => {
|
||||
this._states.forEach(key => {
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (props[key] != undefined) {
|
||||
Object.assign(this, { [key]: props[key] });
|
||||
}
|
||||
});
|
||||
this.setEdgelessTool({
|
||||
type: 'affine:note',
|
||||
childFlavour: this.childFlavour,
|
||||
childType: this.childType,
|
||||
tip: this.tip,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const appTheme = this.edgeless.std.get(ThemeProvider).app$.value;
|
||||
|
||||
return html`<edgeless-toolbar-button
|
||||
class="edgeless-note-button"
|
||||
.tooltip=${this.popper ? '' : getTooltipWithShortcut('Note', 'N')}
|
||||
.tooltipOffset=${5}
|
||||
>
|
||||
<div
|
||||
class="note-root"
|
||||
data-app-theme=${appTheme}
|
||||
@click=${this._toggleNoteMenu}
|
||||
style="--paper-bg: ${this._noteBg$.value}"
|
||||
>
|
||||
<div class="paper">${toShapeNotToAdapt}</div>
|
||||
<div class="edgeless-toolbar-note-icon link">${LinkIcon}</div>
|
||||
<div class="edgeless-toolbar-note-icon heading">${Heading1Icon}</div>
|
||||
<div class="edgeless-toolbar-note-icon text">${TextIcon}</div>
|
||||
</div>
|
||||
</edgeless-toolbar-button>`;
|
||||
}
|
||||
|
||||
// TODO: better to extract these states outside of component?
|
||||
@state()
|
||||
accessor childFlavour: NoteToolOption['childFlavour'] = 'affine:paragraph';
|
||||
|
||||
@state()
|
||||
accessor childType = 'text';
|
||||
|
||||
@state()
|
||||
accessor tip = 'Note';
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ArrowUpIcon, NoteIcon } from '@blocksuite/affine-components/icons';
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { getTooltipWithShortcut } from '../../../components/utils.js';
|
||||
import type { NoteToolOption } from '../../../gfx-tool/note-tool.js';
|
||||
import { createPopper, type MenuPopper } from '../common/create-popper.js';
|
||||
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
|
||||
import type { EdgelessNoteMenu } from './note-menu.js';
|
||||
|
||||
export class EdgelessNoteToolButton extends QuickToolMixin(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.arrow-up-icon {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 2px;
|
||||
font-size: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
private _noteMenu: MenuPopper<EdgelessNoteMenu> | null = null;
|
||||
|
||||
private _states = ['childFlavour', 'childType', 'tip'] as const;
|
||||
|
||||
override type: GfxToolsFullOptionValue['type'] = 'affine:note';
|
||||
|
||||
private _disposeMenu() {
|
||||
this._noteMenu?.dispose();
|
||||
this._noteMenu = null;
|
||||
}
|
||||
|
||||
private _toggleNoteMenu() {
|
||||
if (this._noteMenu) {
|
||||
this._disposeMenu();
|
||||
this.requestUpdate();
|
||||
} else {
|
||||
this.edgeless.gfx.tool.setTool('affine:note', {
|
||||
childFlavour: this.childFlavour,
|
||||
childType: this.childType,
|
||||
tip: this.tip,
|
||||
});
|
||||
this._noteMenu = createPopper('edgeless-note-menu', this);
|
||||
|
||||
this._noteMenu.element.edgeless = this.edgeless;
|
||||
this._noteMenu.element.childFlavour = this.childFlavour;
|
||||
this._noteMenu.element.childType = this.childType;
|
||||
this._noteMenu.element.tip = this.tip;
|
||||
this._noteMenu.element.onChange = (
|
||||
props: Partial<{
|
||||
childFlavour: NoteToolOption['childFlavour'];
|
||||
childType: string | null;
|
||||
tip: string;
|
||||
}>
|
||||
) => {
|
||||
this._states.forEach(key => {
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (props[key] != undefined) {
|
||||
Object.assign(this, { [key]: props[key] });
|
||||
}
|
||||
});
|
||||
this.edgeless.gfx.tool.setTool('affine:note', {
|
||||
childFlavour: this.childFlavour,
|
||||
childType: this.childType,
|
||||
tip: this.tip,
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
const value = this.edgeless.gfx.tool.currentToolName$.value;
|
||||
if (value !== 'affine:note') {
|
||||
this._disposeMenu();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this._disposeMenu();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { active } = this;
|
||||
const arrowColor = active ? 'currentColor' : 'var(--affine-icon-secondary)';
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
class="edgeless-note-button"
|
||||
.tooltip=${this._noteMenu ? '' : getTooltipWithShortcut('Note', 'N')}
|
||||
.tooltipOffset=${17}
|
||||
.active=${active}
|
||||
.iconContainerPadding=${6}
|
||||
@click=${() => {
|
||||
this._toggleNoteMenu();
|
||||
}}
|
||||
>
|
||||
${NoteIcon}
|
||||
<span class="arrow-up-icon" style=${styleMap({ color: arrowColor })}>
|
||||
${ArrowUpIcon}
|
||||
</span>
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor childFlavour: NoteToolOption['childFlavour'] = 'affine:paragraph';
|
||||
|
||||
@state()
|
||||
accessor childType = 'text';
|
||||
|
||||
@state()
|
||||
accessor tip = 'Text';
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-note-tool-button': EdgelessNoteToolButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { FrameOrderAdjustmentIcon } from '@blocksuite/affine-components/icons';
|
||||
import type { FrameBlockModel } from '@blocksuite/affine-model';
|
||||
import { createButtonPopper } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
|
||||
import type { EdgelessFrameOrderMenu } from './frame-order-menu.js';
|
||||
|
||||
export class EdgelessFrameOrderButton extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
edgeless-frame-order-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
edgeless-frame-order-menu[data-show] {
|
||||
display: initial;
|
||||
}
|
||||
`;
|
||||
|
||||
private _edgelessFrameOrderPopper: ReturnType<
|
||||
typeof createButtonPopper
|
||||
> | null = null;
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._edgelessFrameOrderPopper?.dispose();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._edgelessFrameOrderPopper = createButtonPopper(
|
||||
this._edgelessFrameOrderButton,
|
||||
this._edgelessFrameOrderMenu,
|
||||
({ display }) => this.setPopperShow(display === 'show'),
|
||||
{
|
||||
mainAxis: 22,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
const { readonly } = this.edgeless.doc;
|
||||
return html`
|
||||
<style>
|
||||
.edgeless-frame-order-button svg {
|
||||
color: ${readonly ? 'var(--affine-text-disable-color)' : 'inherit'};
|
||||
}
|
||||
</style>
|
||||
<edgeless-tool-icon-button
|
||||
class="edgeless-frame-order-button"
|
||||
.tooltip=${this.popperShow ? '' : 'Frame Order'}
|
||||
@click=${() => {
|
||||
if (readonly) return;
|
||||
this._edgelessFrameOrderPopper?.toggle();
|
||||
}}
|
||||
.iconContainerPadding=${0}
|
||||
>
|
||||
${FrameOrderAdjustmentIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
<edgeless-frame-order-menu .edgeless=${this.edgeless}>
|
||||
</edgeless-frame-order-menu>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.edgeless-frame-order-button')
|
||||
private accessor _edgelessFrameOrderButton!: HTMLElement;
|
||||
|
||||
@query('edgeless-frame-order-menu')
|
||||
private accessor _edgelessFrameOrderMenu!: EdgelessFrameOrderMenu;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor frames!: FrameBlockModel[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor popperShow = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor setPopperShow: (show: boolean) => void = () => {};
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { generateKeyBetweenV2 } from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
DisposableGroup,
|
||||
SignalWatcher,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
|
||||
|
||||
export class EdgelessFrameOrderMenu extends SignalWatcher(
|
||||
WithDisposable(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
}
|
||||
.edgeless-frame-order-items-container {
|
||||
max-height: 281px;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-menu-shadow);
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.edgeless-frame-order-items-container.embed {
|
||||
padding: 0;
|
||||
background: unset;
|
||||
box-shadow: unset;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
box-sizing: border-box;
|
||||
width: 256px;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.draggable:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.item:hover .drag-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-indicator {
|
||||
cursor: pointer;
|
||||
width: 4px;
|
||||
height: 12px;
|
||||
border-radius: 1px;
|
||||
opacity: 0.2;
|
||||
background: var(--affine-placeholder-color);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
color: var(--affine-text-primary-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.clone {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 8px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
box-shadow: var(--affine-menu-shadow);
|
||||
background-color: var(--affine-white);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.indicator-line {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 8px;
|
||||
background-color: var(--affine-primary-color);
|
||||
height: 1px;
|
||||
width: 90%;
|
||||
}
|
||||
`;
|
||||
|
||||
private get _frames() {
|
||||
return this.edgeless.service.frames;
|
||||
}
|
||||
|
||||
private _bindEvent() {
|
||||
const { _disposables } = this;
|
||||
|
||||
_disposables.addFromEvent(this._container, 'wheel', e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
_disposables.addFromEvent(this._container, 'pointerdown', e => {
|
||||
const ele = e.target as HTMLElement;
|
||||
const draggable = ele.closest('.draggable');
|
||||
if (!draggable) return;
|
||||
const clone = this._clone;
|
||||
const indicatorLine = this._indicatorLine;
|
||||
clone.style.visibility = 'visible';
|
||||
|
||||
const rect = draggable.getBoundingClientRect();
|
||||
|
||||
const index = Number(draggable.getAttribute('index'));
|
||||
this._curIndex = index;
|
||||
let newIndex = -1;
|
||||
|
||||
const containerRect = this._container.getBoundingClientRect();
|
||||
const start = containerRect.top + 8;
|
||||
const end = containerRect.bottom;
|
||||
|
||||
const shiftX = e.clientX - rect.left;
|
||||
const shiftY = e.clientY - rect.top;
|
||||
function moveAt(x: number, y: number) {
|
||||
clone.style.left = x - containerRect.left - shiftX + 'px';
|
||||
clone.style.top = y - containerRect.top - shiftY + 'px';
|
||||
}
|
||||
|
||||
function isInsideContainer(e: PointerEvent) {
|
||||
return e.clientY >= start && e.clientY <= end;
|
||||
}
|
||||
moveAt(e.clientX, e.clientY);
|
||||
|
||||
this._disposables.addFromEvent(document, 'pointermove', e => {
|
||||
indicatorLine.style.visibility = 'visible';
|
||||
moveAt(e.clientX, e.clientY);
|
||||
if (isInsideContainer(e)) {
|
||||
const relativeY = e.pageY + this._container.scrollTop - start;
|
||||
let top = 0;
|
||||
if (relativeY < rect.height / 2) {
|
||||
newIndex = 0;
|
||||
top = this.embed ? -2 : 4;
|
||||
} else {
|
||||
newIndex = Math.ceil(
|
||||
(relativeY - rect.height / 2) / (rect.height + 10)
|
||||
);
|
||||
top =
|
||||
(this.embed ? -2 : 7.5) +
|
||||
newIndex * rect.height +
|
||||
(newIndex - 0.5) * 4;
|
||||
}
|
||||
|
||||
indicatorLine.style.top = top - this._container.scrollTop + 'px';
|
||||
return;
|
||||
}
|
||||
newIndex = -1;
|
||||
});
|
||||
|
||||
this._disposables.addFromEvent(document, 'pointerup', () => {
|
||||
clone.style.visibility = 'hidden';
|
||||
indicatorLine.style.visibility = 'hidden';
|
||||
if (
|
||||
newIndex >= 0 &&
|
||||
newIndex <= this._frames.length &&
|
||||
newIndex !== index &&
|
||||
newIndex !== index + 1
|
||||
) {
|
||||
const frameMgr = this.edgeless.service.frame;
|
||||
// Legacy compatibility
|
||||
frameMgr.refreshLegacyFrameOrder();
|
||||
|
||||
const before = this._frames[newIndex - 1]?.presentationIndex || null;
|
||||
const after = this._frames[newIndex]?.presentationIndex || null;
|
||||
|
||||
const frame = this._frames[index];
|
||||
|
||||
this.edgeless.service.updateElement(frame.id, {
|
||||
presentationIndex: generateKeyBetweenV2(before, after),
|
||||
});
|
||||
this.edgeless.doc.captureSync();
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
this._disposables.dispose();
|
||||
this._disposables = new DisposableGroup();
|
||||
this._bindEvent();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._disposables.dispose();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._bindEvent();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const frame = this._frames[this._curIndex];
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="edgeless-frame-order-items-container ${this.embed
|
||||
? 'embed'
|
||||
: ''}"
|
||||
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
${repeat(
|
||||
this._frames,
|
||||
frame => frame.id,
|
||||
(frame, index) => html`
|
||||
<div class="item draggable" id=${frame.id} index=${index}>
|
||||
<div class="drag-indicator"></div>
|
||||
<div class="title">${frame.title.toString()}</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
<div class="indicator-line"></div>
|
||||
<div class="clone item">
|
||||
${frame
|
||||
? html`<div class="drag-indicator"></div>
|
||||
<div class="index">${this._curIndex + 1}</div>
|
||||
<div class="title">${frame.title.toString()}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.clone')
|
||||
private accessor _clone!: HTMLDivElement;
|
||||
|
||||
@query('.edgeless-frame-order-items-container')
|
||||
private accessor _container!: HTMLDivElement;
|
||||
|
||||
@state()
|
||||
private accessor _curIndex = -1;
|
||||
|
||||
@query('.indicator-line')
|
||||
private accessor _indicatorLine!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor embed = false;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-frame-order-menu': EdgelessFrameOrderMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { NavigatorSettingsIcon } from '@blocksuite/affine-components/icons';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import { createButtonPopper } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
|
||||
|
||||
export class EdgelessNavigatorSettingButton extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.navigator-setting-menu {
|
||||
display: none;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background-color: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-menu-shadow);
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
.navigator-setting-menu[data-show] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.item-container {
|
||||
padding: 4px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-width: 264px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.item-container.header {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: 0px 4px;
|
||||
line-height: 22px;
|
||||
font-size: var(--affine-font-sm);
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
.text.title {
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
font-size: var(--affine-font-xs);
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.divider::before {
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--affine-border-color);
|
||||
}
|
||||
`;
|
||||
|
||||
private _navigatorSettingPopper?: ReturnType<
|
||||
typeof createButtonPopper
|
||||
> | null = null;
|
||||
|
||||
private _onBlackBackgroundChange = (checked: boolean) => {
|
||||
this.blackBackground = checked;
|
||||
this.edgeless.slots.navigatorSettingUpdated.emit({
|
||||
blackBackground: this.blackBackground,
|
||||
});
|
||||
};
|
||||
|
||||
private _tryRestoreSettings() {
|
||||
const blackBackground = this.edgeless.std
|
||||
.get(EditPropsStore)
|
||||
.getStorage('presentBlackBackground');
|
||||
this.blackBackground = blackBackground ?? true;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._tryRestoreSettings();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this._navigatorSettingPopper?.dispose();
|
||||
this._navigatorSettingPopper = null;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._navigatorSettingPopper = createButtonPopper(
|
||||
this._navigatorSettingButton,
|
||||
this._navigatorSettingMenu,
|
||||
({ display }) => this.setPopperShow(display === 'show'),
|
||||
{
|
||||
mainAxis: 22,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
class="navigator-setting-button"
|
||||
.tooltip=${this.popperShow ? '' : 'Settings'}
|
||||
@click=${() => {
|
||||
this._navigatorSettingPopper?.toggle();
|
||||
}}
|
||||
.iconContainerPadding=${0}
|
||||
>
|
||||
${NavigatorSettingsIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
|
||||
<div
|
||||
class="navigator-setting-menu"
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div class="item-container header">
|
||||
<div class="text title">Playback Settings</div>
|
||||
</div>
|
||||
|
||||
<div class="item-container">
|
||||
<div class="text">Black background</div>
|
||||
|
||||
<toggle-switch
|
||||
.on=${this.blackBackground}
|
||||
.onChange=${this._onBlackBackgroundChange}
|
||||
>
|
||||
</toggle-switch>
|
||||
</div>
|
||||
|
||||
<div class="item-container">
|
||||
<div class="text">Hide toolbar</div>
|
||||
|
||||
<toggle-switch
|
||||
.on=${this.hideToolbar}
|
||||
.onChange=${(checked: boolean) => {
|
||||
this.onHideToolbarChange && this.onHideToolbarChange(checked);
|
||||
}}
|
||||
>
|
||||
</toggle-switch>
|
||||
</div>
|
||||
|
||||
${this.includeFrameOrder
|
||||
? html` <div class="divider"></div>
|
||||
<div class="item-container header">
|
||||
<div class="text title">Frame Order</div>
|
||||
</div>
|
||||
|
||||
<edgeless-frame-order-menu
|
||||
.edgeless=${this.edgeless}
|
||||
.embed=${true}
|
||||
></edgeless-frame-order-menu>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.navigator-setting-button')
|
||||
private accessor _navigatorSettingButton!: HTMLElement;
|
||||
|
||||
@query('.navigator-setting-menu')
|
||||
private accessor _navigatorSettingMenu!: HTMLElement;
|
||||
|
||||
@state()
|
||||
accessor blackBackground = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor hideToolbar = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor includeFrameOrder = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onHideToolbarChange: undefined | ((hideToolbar: boolean) => void) =
|
||||
undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor popperShow = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor setPopperShow: (show: boolean) => void = () => {};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-navigator-setting-button': EdgelessNavigatorSettingButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FrameNavigatorIcon } from '@blocksuite/affine-components/icons';
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
|
||||
import { QuickToolMixin } from '../mixins/quick-tool.mixin.js';
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
|
||||
export class EdgelessPresentButton extends QuickToolMixin(
|
||||
EdgelessToolbarToolMixin(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
}
|
||||
.edgeless-note-button {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
.arrow-up-icon {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 2px;
|
||||
font-size: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
override type: GfxToolsFullOptionValue['type'] = 'frameNavigator';
|
||||
|
||||
override render() {
|
||||
return html`<edgeless-tool-icon-button
|
||||
class="edgeless-frame-navigator-button"
|
||||
.tooltip=${'Present'}
|
||||
.tooltipOffset=${17}
|
||||
.iconContainerPadding=${6}
|
||||
@click=${() => {
|
||||
this.setEdgelessTool({
|
||||
type: 'frameNavigator',
|
||||
});
|
||||
}}
|
||||
>
|
||||
${FrameNavigatorIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-present-button': EdgelessPresentButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { CommonUtils } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
FrameNavigatorNextIcon,
|
||||
FrameNavigatorPrevIcon,
|
||||
NavigatorExitFullScreenIcon,
|
||||
NavigatorFullScreenIcon,
|
||||
StopAIIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import type { FrameBlockModel } from '@blocksuite/affine-model';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
|
||||
import { Bound, SignalWatcher } from '@blocksuite/global/utils';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
|
||||
import type { NavigatorMode } from '../../../../_common/edgeless/frame/consts.js';
|
||||
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
||||
import { isFrameBlock } from '../../utils/query.js';
|
||||
import { launchIntoFullscreen } from '../utils.js';
|
||||
import { EdgelessToolbarToolMixin } from './mixins/tool.mixin.js';
|
||||
|
||||
const { clamp } = CommonUtils;
|
||||
|
||||
export class PresentationToolbar extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
align-items: inherit;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
.full-divider {
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.full-divider::after {
|
||||
content: '';
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: var(--affine-border-color);
|
||||
transform: scaleX(0.5);
|
||||
}
|
||||
.config-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.edgeless-frame-navigator {
|
||||
width: 140px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.edgeless-frame-navigator.dense {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.edgeless-frame-navigator-title {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.edgeless-frame-navigator-count {
|
||||
color: var(--affine-text-secondary-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.edgeless-frame-navigator-stop {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.edgeless-frame-navigator-stop::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.edgeless-frame-navigator-stop:hover::before {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
`;
|
||||
|
||||
private _cachedIndex = -1;
|
||||
|
||||
private _timer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
override type: GfxToolsFullOptionValue['type'] = 'frameNavigator';
|
||||
|
||||
private get _cachedPresentHideToolbar() {
|
||||
return !!this.edgeless.std
|
||||
.get(EditPropsStore)
|
||||
.getStorage('presentHideToolbar');
|
||||
}
|
||||
|
||||
private set _cachedPresentHideToolbar(value) {
|
||||
this.edgeless.std
|
||||
.get(EditPropsStore)
|
||||
.setStorage('presentHideToolbar', !!value);
|
||||
}
|
||||
|
||||
private get _frames(): FrameBlockModel[] {
|
||||
return this.edgeless.service.frames;
|
||||
}
|
||||
|
||||
get dense() {
|
||||
return this.containerWidth < 554;
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.edgeless.host;
|
||||
}
|
||||
|
||||
constructor(edgeless: EdgelessRootBlockComponent) {
|
||||
super();
|
||||
this.edgeless = edgeless;
|
||||
}
|
||||
|
||||
private _bindHotKey() {
|
||||
const handleKeyIfFrameNavigator = (action: () => void) => () => {
|
||||
if (this.edgelessTool.type === 'frameNavigator') {
|
||||
action();
|
||||
}
|
||||
};
|
||||
|
||||
this.edgeless.bindHotKey(
|
||||
{
|
||||
ArrowLeft: handleKeyIfFrameNavigator(() => this._previousFrame()),
|
||||
ArrowRight: handleKeyIfFrameNavigator(() => this._nextFrame()),
|
||||
Escape: handleKeyIfFrameNavigator(() => this._exitPresentation()),
|
||||
},
|
||||
{
|
||||
global: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _exitPresentation() {
|
||||
// When exit presentation mode, we need to set the tool to default or pan
|
||||
// And exit fullscreen
|
||||
this.setEdgelessTool(
|
||||
this.edgeless.doc.readonly
|
||||
? { type: 'pan', panning: false }
|
||||
: { type: 'default' }
|
||||
);
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
private _moveToCurrentFrame() {
|
||||
const current = this._currentFrameIndex;
|
||||
const viewport = this.edgeless.service.viewport;
|
||||
const frame = this._frames[current];
|
||||
|
||||
if (frame) {
|
||||
let bound = Bound.deserialize(frame.xywh);
|
||||
|
||||
if (this._navigatorMode === 'fill') {
|
||||
const vb = viewport.viewportBounds;
|
||||
const center = bound.center;
|
||||
let w, h;
|
||||
if (vb.w / vb.h > bound.w / bound.h) {
|
||||
w = bound.w;
|
||||
h = (w * vb.h) / vb.w;
|
||||
} else {
|
||||
h = bound.h;
|
||||
w = (h * vb.w) / vb.h;
|
||||
}
|
||||
bound = Bound.fromCenter(center, w, h);
|
||||
}
|
||||
|
||||
viewport.setViewportByBound(bound, [0, 0, 0, 0], false);
|
||||
this.edgeless.slots.navigatorFrameChanged.emit(
|
||||
this._frames[this._currentFrameIndex]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _nextFrame() {
|
||||
const frames = this._frames;
|
||||
const min = 0;
|
||||
const max = frames.length - 1;
|
||||
if (this._currentFrameIndex === frames.length - 1) {
|
||||
toast(this.host, 'You have reached the last frame');
|
||||
} else {
|
||||
this._currentFrameIndex = clamp(this._currentFrameIndex + 1, min, max);
|
||||
}
|
||||
}
|
||||
|
||||
private _previousFrame() {
|
||||
const frames = this._frames;
|
||||
const min = 0;
|
||||
const max = frames.length - 1;
|
||||
if (this._currentFrameIndex === 0) {
|
||||
toast(this.host, 'You have reached the first frame');
|
||||
} else {
|
||||
this._currentFrameIndex = clamp(this._currentFrameIndex - 1, min, max);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle fullscreen, but keep edgeless tool to frameNavigator
|
||||
* If already fullscreen, exit fullscreen
|
||||
* If not fullscreen, enter fullscreen
|
||||
*/
|
||||
private _toggleFullScreen() {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(console.error);
|
||||
this._fullScreenMode = false;
|
||||
} else {
|
||||
launchIntoFullscreen(this.edgeless.viewportElement);
|
||||
this._fullScreenMode = true;
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
const { _disposables, edgeless } = this;
|
||||
|
||||
_disposables.add(
|
||||
effect(() => {
|
||||
const currentTool = this.edgeless.gfx.tool.currentToolOption$.value;
|
||||
|
||||
if (currentTool?.type === 'frameNavigator') {
|
||||
this._cachedIndex = this._currentFrameIndex;
|
||||
this._navigatorMode = currentTool.mode ?? this._navigatorMode;
|
||||
if (isFrameBlock(edgeless.service.selection.selectedElements[0])) {
|
||||
this._cachedIndex = this._frames.findIndex(
|
||||
frame =>
|
||||
frame.id === edgeless.service.selection.selectedElements[0].id
|
||||
);
|
||||
}
|
||||
if (this._frames.length === 0)
|
||||
toast(
|
||||
this.host,
|
||||
'The presentation requires at least 1 frame. You can firstly create a frame.',
|
||||
5000
|
||||
);
|
||||
this._toggleFullScreen();
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const { _disposables, edgeless } = this;
|
||||
|
||||
this._bindHotKey();
|
||||
|
||||
_disposables.add(
|
||||
edgeless.slots.navigatorSettingUpdated.on(({ fillScreen }) => {
|
||||
if (fillScreen !== undefined) {
|
||||
this._navigatorMode = fillScreen ? 'fill' : 'fit';
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
_disposables.addFromEvent(document, 'fullscreenchange', () => {
|
||||
if (document.fullscreenElement) {
|
||||
// When enter fullscreen, we need to set current frame to the cached index
|
||||
this._timer = setTimeout(() => {
|
||||
this._currentFrameIndex = this._cachedIndex;
|
||||
}, 400);
|
||||
} else {
|
||||
// When exit fullscreen, we need to clear the timer
|
||||
clearTimeout(this._timer);
|
||||
if (
|
||||
this.edgelessTool.type === 'frameNavigator' &&
|
||||
this._fullScreenMode
|
||||
) {
|
||||
this.setEdgelessTool(
|
||||
this.edgeless.doc.readonly
|
||||
? { type: 'pan', panning: false }
|
||||
: { type: 'default' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => this._moveToCurrentFrame(), 400);
|
||||
this.edgeless.slots.fullScreenToggled.emit();
|
||||
});
|
||||
|
||||
this._navigatorMode =
|
||||
this.edgeless.std.get(EditPropsStore).getStorage('presentFillScreen') ===
|
||||
true
|
||||
? 'fill'
|
||||
: 'fit';
|
||||
}
|
||||
|
||||
override render() {
|
||||
const current = this._currentFrameIndex;
|
||||
const frames = this._frames;
|
||||
const frame = frames[current];
|
||||
|
||||
return html`
|
||||
<style>
|
||||
:host {
|
||||
display: ${this.visible ? 'flex' : 'none'};
|
||||
}
|
||||
</style>
|
||||
<edgeless-tool-icon-button
|
||||
.iconContainerPadding=${0}
|
||||
.tooltip=${'Previous'}
|
||||
@click=${() => this._previousFrame()}
|
||||
>
|
||||
${FrameNavigatorPrevIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
|
||||
<div class="edgeless-frame-navigator ${this.dense ? 'dense' : ''}">
|
||||
${this.dense
|
||||
? nothing
|
||||
: html`<span
|
||||
style="color: ${cssVar('textPrimaryColor')}"
|
||||
class="edgeless-frame-navigator-title"
|
||||
@click=${() => this._moveToCurrentFrame()}
|
||||
>
|
||||
${frame?.title ?? 'no frame'}
|
||||
</span>`}
|
||||
|
||||
<span class="edgeless-frame-navigator-count">
|
||||
${frames.length === 0 ? 0 : current + 1} / ${frames.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${'Next'}
|
||||
@click=${() => this._nextFrame()}
|
||||
.iconContainerPadding=${0}
|
||||
>
|
||||
${FrameNavigatorNextIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
|
||||
<div class="full-divider"></div>
|
||||
|
||||
<div class="config-buttons">
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${document.fullscreenElement
|
||||
? 'Exit Full Screen'
|
||||
: 'Enter Full Screen'}
|
||||
@click=${() => this._toggleFullScreen()}
|
||||
.iconContainerPadding=${0}
|
||||
.iconContainerWidth=${'24px'}
|
||||
>
|
||||
${document.fullscreenElement
|
||||
? NavigatorExitFullScreenIcon
|
||||
: NavigatorFullScreenIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
|
||||
${this.dense
|
||||
? nothing
|
||||
: html`<edgeless-frame-order-button
|
||||
.popperShow=${this.frameMenuShow}
|
||||
.setPopperShow=${this.setFrameMenuShow}
|
||||
.edgeless=${this.edgeless}
|
||||
>
|
||||
</edgeless-frame-order-button>`}
|
||||
|
||||
<edgeless-navigator-setting-button
|
||||
.edgeless=${this.edgeless}
|
||||
.hideToolbar=${this._cachedPresentHideToolbar}
|
||||
.onHideToolbarChange=${(hideToolbar: boolean) => {
|
||||
this._cachedPresentHideToolbar = hideToolbar;
|
||||
}}
|
||||
.popperShow=${this.settingMenuShow}
|
||||
.setPopperShow=${this.setSettingMenuShow}
|
||||
.includeFrameOrder=${this.dense}
|
||||
>
|
||||
</edgeless-navigator-setting-button>
|
||||
</div>
|
||||
|
||||
<div class="full-divider"></div>
|
||||
|
||||
<button
|
||||
class="edgeless-frame-navigator-stop"
|
||||
@click=${this._exitPresentation}
|
||||
style="background: ${cssVar('warningColor')}"
|
||||
>
|
||||
${StopAIIcon}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
protected override updated(changedProperties: PropertyValues) {
|
||||
if (
|
||||
changedProperties.has('_currentFrameIndex') &&
|
||||
this.edgelessTool.type === 'frameNavigator'
|
||||
) {
|
||||
this._moveToCurrentFrame();
|
||||
}
|
||||
}
|
||||
|
||||
@state({
|
||||
hasChanged() {
|
||||
return true;
|
||||
},
|
||||
})
|
||||
private accessor _currentFrameIndex = 0;
|
||||
|
||||
private accessor _fullScreenMode = true;
|
||||
|
||||
@state()
|
||||
private accessor _navigatorMode: NavigatorMode = 'fit';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor containerWidth = 1920;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor frameMenuShow = false;
|
||||
|
||||
@property()
|
||||
accessor setFrameMenuShow: (show: boolean) => void = () => {};
|
||||
|
||||
@property()
|
||||
accessor setSettingMenuShow: (show: boolean) => void = () => {};
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor settingMenuShow = false;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
accessor visible = true;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'presentation-toolbar': PresentationToolbar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { CanvasElementType } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
ellipseSvg,
|
||||
roundedSvg,
|
||||
triangleSvg,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import {
|
||||
getShapeRadius,
|
||||
getShapeType,
|
||||
ShapeType,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditPropsStore,
|
||||
TelemetryProvider,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { assertExists, SignalWatcher } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { ShapeTool } from '../../../gfx-tool/shape-tool.js';
|
||||
import { EdgelessDraggableElementController } from '../common/draggable/draggable-element.controller.js';
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
import type { DraggableShape } from './utils.js';
|
||||
import { buildVariablesObject } from './utils.js';
|
||||
|
||||
const shapes: DraggableShape[] = [];
|
||||
// to move shapes together
|
||||
const oy = -2;
|
||||
const ox = 0;
|
||||
shapes.push({
|
||||
name: 'roundedRect',
|
||||
svg: roundedSvg,
|
||||
style: {
|
||||
default: { x: -9, y: 6 },
|
||||
hover: { y: -5, z: 1 },
|
||||
next: { y: 60 },
|
||||
},
|
||||
});
|
||||
shapes.push({
|
||||
name: ShapeType.Ellipse,
|
||||
svg: ellipseSvg,
|
||||
style: {
|
||||
default: { x: -20, y: 31 },
|
||||
hover: { y: 15, z: 1 },
|
||||
next: { y: 64 },
|
||||
},
|
||||
});
|
||||
shapes.push({
|
||||
name: ShapeType.Triangle,
|
||||
svg: triangleSvg,
|
||||
style: {
|
||||
default: { x: 18, y: 25 },
|
||||
hover: { y: 7, z: 1 },
|
||||
next: { y: 64 },
|
||||
},
|
||||
});
|
||||
shapes.forEach(s => {
|
||||
Object.values(s.style).forEach(style => {
|
||||
if (style.y) (style.y as number) += oy;
|
||||
if (style.x) (style.x as number) += ox;
|
||||
});
|
||||
});
|
||||
|
||||
export class EdgelessToolbarShapeDraggable extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.edgeless-shape-draggable {
|
||||
/* avoid shadow clipping */
|
||||
--shadow-safe-area: 10px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
width: calc(100% + 2 * var(--shadow-safe-area));
|
||||
height: calc(100% + var(--shadow-safe-area));
|
||||
padding-top: var(--shadow-safe-area);
|
||||
padding-left: var(--shadow-safe-area);
|
||||
padding-right: var(--shadow-safe-area);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shape {
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
position: absolute;
|
||||
transition:
|
||||
transform 0.3s,
|
||||
z-index 0.1s;
|
||||
transform: translateX(var(--default-x, 0)) translateY(var(--default-y, 0))
|
||||
scale(var(--default-s, 1));
|
||||
z-index: var(--default-z, 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
.shape svg {
|
||||
display: block;
|
||||
}
|
||||
.shape svg path,
|
||||
.shape svg circle,
|
||||
.shape svg rect {
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
}
|
||||
.shape:hover,
|
||||
.shape.cancel {
|
||||
transform: translateX(var(--hover-x, 0)) translateY(var(--hover-y, 0))
|
||||
scale(var(--hover-s, 1));
|
||||
z-index: var(--hover-z, 0);
|
||||
}
|
||||
.shape.next {
|
||||
transition: all 0.5s cubic-bezier(0.39, 0.28, 0.09, 0.95);
|
||||
pointer-events: none;
|
||||
transform: translateX(var(--next-x, 0)) translateY(var(--next-y, 0))
|
||||
scale(var(--next-s, 1));
|
||||
}
|
||||
.shape.next.coming {
|
||||
transform: translateX(var(--default-x, 0)) translateY(var(--default-y, 0))
|
||||
scale(var(--default-s, 1));
|
||||
}
|
||||
`;
|
||||
|
||||
draggableController!: EdgelessDraggableElementController<DraggableShape>;
|
||||
|
||||
draggingShape: DraggableShape['name'] = 'roundedRect';
|
||||
|
||||
override type = 'shape' as const;
|
||||
|
||||
get shapeShadow() {
|
||||
return this.theme === 'dark'
|
||||
? '0 0 7px rgba(0, 0, 0, .22)'
|
||||
: '0 0 5px rgba(0, 0, 0, .2)';
|
||||
}
|
||||
|
||||
private _setShapeOverlayLock(lock: boolean) {
|
||||
const controller = this.edgeless.gfx.tool.currentTool$.peek();
|
||||
if (controller instanceof ShapeTool) {
|
||||
controller.setDisableOverlay(lock);
|
||||
}
|
||||
}
|
||||
|
||||
initDragController() {
|
||||
if (!this.edgeless || !this.toolbarContainer) return;
|
||||
if (this.draggableController) return;
|
||||
this.draggableController = new EdgelessDraggableElementController(this, {
|
||||
service: this.edgeless.service,
|
||||
edgeless: this.edgeless,
|
||||
scopeElement: this.toolbarContainer,
|
||||
standardWidth: 100,
|
||||
clickToDrag: true,
|
||||
onOverlayCreated: (overlay, element) => {
|
||||
const shapeName =
|
||||
this.draggableController.states.draggingElement?.data.name;
|
||||
if (!shapeName) return;
|
||||
|
||||
this.setEdgelessTool({
|
||||
type: 'shape',
|
||||
shapeName,
|
||||
});
|
||||
const controller = this.edgeless.gfx.tool.currentTool$.peek();
|
||||
if (controller instanceof ShapeTool) {
|
||||
controller.clearOverlay();
|
||||
}
|
||||
overlay.element.style.filter = `drop-shadow(${this.shapeShadow})`;
|
||||
this.readyToDrop = true;
|
||||
this.draggingShape = element.data.name;
|
||||
},
|
||||
onDrop: (el, bound) => {
|
||||
const xywh = bound.serialize();
|
||||
const shape = el.data;
|
||||
const id = this.edgeless.service.addElement(CanvasElementType.SHAPE, {
|
||||
shapeType: getShapeType(shape.name),
|
||||
xywh,
|
||||
radius: getShapeRadius(shape.name),
|
||||
});
|
||||
|
||||
this.edgeless.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('CanvasElementAdded', {
|
||||
control: 'toolbar:dnd',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'shape',
|
||||
other: {
|
||||
shapeType: getShapeType(shape.name),
|
||||
},
|
||||
});
|
||||
|
||||
this._setShapeOverlayLock(false);
|
||||
this.readyToDrop = false;
|
||||
|
||||
this.edgeless.gfx.tool.setTool('default');
|
||||
this.edgeless.gfx.selection.set({
|
||||
elements: [id],
|
||||
editing: false,
|
||||
});
|
||||
},
|
||||
onCanceled: () => {
|
||||
this._setShapeOverlayLock(false);
|
||||
this.readyToDrop = false;
|
||||
},
|
||||
onElementClick: el => {
|
||||
this.onShapeClick?.(el.data);
|
||||
this._setShapeOverlayLock(true);
|
||||
},
|
||||
onEnterOrLeaveScope: (overlay, isOutside) => {
|
||||
overlay.element.style.filter = isOutside
|
||||
? 'none'
|
||||
: `drop-shadow(${this.shapeShadow})`;
|
||||
},
|
||||
});
|
||||
|
||||
this.edgeless.bindHotKey(
|
||||
{
|
||||
s: ctx => {
|
||||
// `page.keyboard.press('Shift+s')` in playwright will also trigger this 's' key event
|
||||
if (ctx.get('keyboardState').raw.shiftKey) return;
|
||||
|
||||
const service = this.edgeless.service;
|
||||
if (service.locked || service.selection.editing) return;
|
||||
|
||||
if (this.readyToDrop) {
|
||||
const activeIndex = shapes.findIndex(
|
||||
s => s.name === this.draggingShape
|
||||
);
|
||||
const nextIndex = (activeIndex + 1) % shapes.length;
|
||||
const next = shapes[nextIndex];
|
||||
this.draggingShape = next.name;
|
||||
|
||||
this.draggableController.cancelWithoutAnimation();
|
||||
}
|
||||
|
||||
const el = this.shapeContainer.querySelector(
|
||||
`.shape.${this.draggingShape}`
|
||||
) as HTMLElement;
|
||||
assertExists(el, 'Edgeless toolbar Shape element not found');
|
||||
const { x, y } = service.gfx.tool.lastMousePos$.peek();
|
||||
const { left, top } = this.edgeless.viewport;
|
||||
const clientPos = { x: x + left, y: y + top };
|
||||
this.draggableController.clickToDrag(el, clientPos);
|
||||
},
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { cancelled, dragOut, draggingElement } =
|
||||
this.draggableController?.states || {};
|
||||
const draggingShape = draggingElement?.data;
|
||||
return html`<div class="edgeless-shape-draggable">
|
||||
${repeat(
|
||||
shapes,
|
||||
s => s.name,
|
||||
shape => {
|
||||
const isBeingDragged = draggingShape?.name === shape.name;
|
||||
const { fillColor, strokeColor } =
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value[
|
||||
`shape:${shape.name}`
|
||||
] || {};
|
||||
const color = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(fillColor);
|
||||
const stroke = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(strokeColor);
|
||||
const baseStyle = {
|
||||
...buildVariablesObject(shape.style),
|
||||
filter: `drop-shadow(${this.shapeShadow})`,
|
||||
color,
|
||||
stroke,
|
||||
};
|
||||
const currStyle = styleMap({
|
||||
...baseStyle,
|
||||
opacity: isBeingDragged ? 0 : 1,
|
||||
});
|
||||
const nextStyle = styleMap(baseStyle);
|
||||
return html`${isBeingDragged
|
||||
? html`<div
|
||||
style=${nextStyle}
|
||||
class=${classMap({
|
||||
shape: true,
|
||||
next: true,
|
||||
coming: !!dragOut && !cancelled,
|
||||
})}
|
||||
>
|
||||
${shape.svg}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div
|
||||
style=${currStyle}
|
||||
class=${classMap({
|
||||
shape: true,
|
||||
[shape.name]: true,
|
||||
cancel: isBeingDragged && !dragOut,
|
||||
})}
|
||||
@mousedown=${(e: MouseEvent) =>
|
||||
this.draggableController.onMouseDown(e, {
|
||||
data: shape,
|
||||
preview: shape.svg,
|
||||
})}
|
||||
@touchstart=${(e: TouchEvent) =>
|
||||
this.draggableController.onTouchStart(e, {
|
||||
data: shape,
|
||||
preview: shape.svg,
|
||||
})}
|
||||
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
${shape.svg}
|
||||
</div>`;
|
||||
}
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override updated(_changedProperties: Map<PropertyKey, unknown>) {
|
||||
const controllerRequiredProps = ['edgeless', 'toolbarContainer'] as const;
|
||||
if (
|
||||
controllerRequiredProps.some(p => _changedProperties.has(p)) &&
|
||||
!this.draggableController
|
||||
) {
|
||||
this.initDragController();
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onShapeClick: (shape: DraggableShape) => void = () => {};
|
||||
|
||||
@state()
|
||||
accessor readyToDrop = false;
|
||||
|
||||
@query('.edgeless-shape-draggable')
|
||||
accessor shapeContainer!: HTMLDivElement;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-toolbar-shape-draggable': EdgelessToolbarShapeDraggable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
DiamondIcon,
|
||||
EllipseIcon,
|
||||
RoundedRectangleIcon,
|
||||
ScribbledDiamondIcon,
|
||||
ScribbledEllipseIcon,
|
||||
ScribbledRoundedRectangleIcon,
|
||||
ScribbledSquareIcon,
|
||||
ScribbledTriangleIcon,
|
||||
SquareIcon,
|
||||
TriangleIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { ShapeType } from '@blocksuite/affine-model';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import type { ShapeToolOption } from '../../../gfx-tool/shape-tool.js';
|
||||
|
||||
type Config = {
|
||||
name: ShapeToolOption['shapeName'];
|
||||
generalIcon: TemplateResult<1>;
|
||||
scribbledIcon: TemplateResult<1>;
|
||||
tooltip: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const ShapeComponentConfig: Config[] = [
|
||||
{
|
||||
name: ShapeType.Rect,
|
||||
generalIcon: SquareIcon,
|
||||
scribbledIcon: ScribbledSquareIcon,
|
||||
tooltip: 'Square',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
name: ShapeType.Ellipse,
|
||||
generalIcon: EllipseIcon,
|
||||
scribbledIcon: ScribbledEllipseIcon,
|
||||
tooltip: 'Ellipse',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
name: ShapeType.Diamond,
|
||||
generalIcon: DiamondIcon,
|
||||
scribbledIcon: ScribbledDiamondIcon,
|
||||
tooltip: 'Diamond',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
name: ShapeType.Triangle,
|
||||
generalIcon: TriangleIcon,
|
||||
scribbledIcon: ScribbledTriangleIcon,
|
||||
tooltip: 'Triangle',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
name: 'roundedRect',
|
||||
generalIcon: RoundedRectangleIcon,
|
||||
scribbledIcon: ScribbledRoundedRectangleIcon,
|
||||
tooltip: 'Rounded rectangle',
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const ShapeComponentConfigMap = ShapeComponentConfig.reduce(
|
||||
(acc, config) => {
|
||||
acc[config.name] = config;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<Config['name'], Config>
|
||||
);
|
||||
|
||||
export const SHAPE_COLOR_PREFIX = '--affine-palette-shape-';
|
||||
export const LINE_COLOR_PREFIX = '--affine-palette-line-';
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
GeneralStyleIcon,
|
||||
ScribbledStyleIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import {
|
||||
DEFAULT_SHAPE_FILL_COLOR,
|
||||
LineColor,
|
||||
SHAPE_FILL_COLORS,
|
||||
type ShapeFillColor,
|
||||
type ShapeName,
|
||||
ShapeStyle,
|
||||
ShapeType,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EditPropsStore,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { Signal } from '@preact/signals-core';
|
||||
import { computed, effect, signal } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
|
||||
import { type ColorEvent, isTransparent } from '../../panel/color-panel.js';
|
||||
import {
|
||||
LINE_COLOR_PREFIX,
|
||||
SHAPE_COLOR_PREFIX,
|
||||
ShapeComponentConfig,
|
||||
} from './shape-menu-config.js';
|
||||
|
||||
export class EdgelessShapeMenu extends SignalWatcher(
|
||||
WithDisposable(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
z-index: -1;
|
||||
}
|
||||
.menu-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.shape-type-container,
|
||||
.shape-style-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.shape-type-container svg,
|
||||
.shape-style-container svg {
|
||||
fill: var(--affine-icon-color);
|
||||
stroke: none;
|
||||
}
|
||||
menu-divider {
|
||||
height: 24px;
|
||||
margin: 0 9px;
|
||||
}
|
||||
`;
|
||||
|
||||
private _shapeName$: Signal<ShapeName> = signal(ShapeType.Rect);
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
private _props$ = computed(() => {
|
||||
const shapeName: ShapeName = this._shapeName$.value;
|
||||
const { shapeStyle, fillColor, strokeColor, radius } =
|
||||
this.edgeless.std.get(EditPropsStore).lastProps$.value[
|
||||
`shape:${shapeName}`
|
||||
];
|
||||
return {
|
||||
shapeStyle,
|
||||
shapeName,
|
||||
fillColor,
|
||||
strokeColor,
|
||||
radius,
|
||||
};
|
||||
});
|
||||
|
||||
private _setFillColor = (fillColor: ShapeFillColor) => {
|
||||
const filled = !isTransparent(fillColor);
|
||||
let strokeColor = fillColor.replace(
|
||||
SHAPE_COLOR_PREFIX,
|
||||
LINE_COLOR_PREFIX
|
||||
) as LineColor;
|
||||
|
||||
if (strokeColor.endsWith('transparent')) {
|
||||
strokeColor = LineColor.Grey;
|
||||
}
|
||||
|
||||
const { shapeName } = this._props$.value;
|
||||
this.edgeless.std
|
||||
.get(EditPropsStore)
|
||||
.recordLastProps(`shape:${shapeName}`, {
|
||||
filled,
|
||||
fillColor,
|
||||
strokeColor,
|
||||
});
|
||||
this.onChange(shapeName);
|
||||
};
|
||||
|
||||
private _setShapeStyle = (shapeStyle: ShapeStyle) => {
|
||||
const { shapeName } = this._props$.value;
|
||||
this.edgeless.std
|
||||
.get(EditPropsStore)
|
||||
.recordLastProps(`shape:${shapeName}`, {
|
||||
shapeStyle,
|
||||
});
|
||||
this.onChange(shapeName);
|
||||
};
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
const value = this.edgeless.gfx.tool.currentToolOption$.value;
|
||||
|
||||
if (value && value.type === 'shape') {
|
||||
this._shapeName$.value = value.shapeName;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { fillColor, shapeStyle, shapeName } = this._props$.value;
|
||||
const color = this.edgeless.std
|
||||
.get(ThemeProvider)
|
||||
.getColorValue(fillColor, DEFAULT_SHAPE_FILL_COLOR);
|
||||
|
||||
return html`
|
||||
<edgeless-slide-menu>
|
||||
<div class="menu-content">
|
||||
<div class="shape-style-container">
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${'General'}
|
||||
.active=${shapeStyle === ShapeStyle.General}
|
||||
.activeMode=${'background'}
|
||||
@click=${() => {
|
||||
this._setShapeStyle(ShapeStyle.General);
|
||||
}}
|
||||
>
|
||||
${GeneralStyleIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${'Scribbled'}
|
||||
.active=${shapeStyle === ShapeStyle.Scribbled}
|
||||
.activeMode=${'background'}
|
||||
@click=${() => {
|
||||
this._setShapeStyle(ShapeStyle.Scribbled);
|
||||
}}
|
||||
>
|
||||
${ScribbledStyleIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
<menu-divider .vertical=${true}></menu-divider>
|
||||
<div class="shape-type-container">
|
||||
${ShapeComponentConfig.map(
|
||||
({ name, generalIcon, scribbledIcon, tooltip }) => {
|
||||
return html`
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${tooltip}
|
||||
.active=${shapeName === name}
|
||||
.activeMode=${'background'}
|
||||
@click=${() => this.onChange(name)}
|
||||
>
|
||||
${shapeStyle === ShapeStyle.General
|
||||
? generalIcon
|
||||
: scribbledIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<menu-divider .vertical=${true}></menu-divider>
|
||||
<edgeless-one-row-color-panel
|
||||
.value=${color}
|
||||
.options=${SHAPE_FILL_COLORS}
|
||||
.hasTransparent=${!this.edgeless.doc.awarenessStore.getFlag(
|
||||
'enable_color_picker'
|
||||
)}
|
||||
@select=${(e: ColorEvent) =>
|
||||
this._setFillColor(e.detail as ShapeFillColor)}
|
||||
></edgeless-one-row-color-panel>
|
||||
</div>
|
||||
</edgeless-slide-menu>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onChange!: (name: ShapeName) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-shape-menu': EdgelessShapeMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { type ShapeName, ShapeType } from '@blocksuite/affine-model';
|
||||
import { SignalWatcher } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
|
||||
import { ShapeTool } from '../../../gfx-tool/shape-tool.js';
|
||||
import { getTooltipWithShortcut } from '../../utils.js';
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
import type { DraggableShape } from './utils.js';
|
||||
|
||||
export class EdgelessShapeToolButton extends EdgelessToolbarToolMixin(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
edgeless-toolbar-button,
|
||||
.shapes {
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
}
|
||||
`;
|
||||
|
||||
private _handleShapeClick = (shape: DraggableShape) => {
|
||||
this.setEdgelessTool(this.type, {
|
||||
shapeName: shape.name,
|
||||
});
|
||||
if (!this.popper) this._toggleMenu();
|
||||
};
|
||||
|
||||
private _handleWrapperClick = () => {
|
||||
if (this.tryDisposePopper()) return;
|
||||
|
||||
this.setEdgelessTool(this.type, {
|
||||
shapeName: ShapeType.Rect,
|
||||
});
|
||||
if (!this.popper) this._toggleMenu();
|
||||
};
|
||||
|
||||
override type = 'shape' as const;
|
||||
|
||||
private _toggleMenu() {
|
||||
this.createPopper('edgeless-shape-menu', this, {
|
||||
setProps: ele => {
|
||||
ele.edgeless = this.edgeless;
|
||||
ele.onChange = (shapeName: ShapeName) => {
|
||||
this.setEdgelessTool(this.type, {
|
||||
shapeName,
|
||||
});
|
||||
this._updateOverlay();
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _updateOverlay() {
|
||||
const controller = this.edgeless.gfx.tool.currentTool$.peek();
|
||||
if (controller instanceof ShapeTool) {
|
||||
controller.createOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { active } = this;
|
||||
|
||||
return html`
|
||||
<edgeless-toolbar-button
|
||||
class="edgeless-shape-button"
|
||||
.tooltip=${this.popper ? '' : getTooltipWithShortcut('Shape', 'S')}
|
||||
.tooltipOffset=${5}
|
||||
.active=${active}
|
||||
>
|
||||
<edgeless-toolbar-shape-draggable
|
||||
.edgeless=${this.edgeless}
|
||||
.toolbarContainer=${this.toolbarContainer}
|
||||
class="shapes"
|
||||
@click=${this._handleWrapperClick}
|
||||
.onShapeClick=${this._handleShapeClick}
|
||||
>
|
||||
</edgeless-toolbar-shape-draggable>
|
||||
</edgeless-toolbar-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-shape-tool-button': EdgelessShapeToolButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { CanvasElementType } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
getShapeRadius,
|
||||
getShapeType,
|
||||
type ShapeName,
|
||||
type ShapeStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { Bound, sleep, WithDisposable } from '@blocksuite/global/utils';
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
|
||||
import { ShapeTool } from '../../../gfx-tool/shape-tool.js';
|
||||
|
||||
export interface Shape {
|
||||
name: ShapeName;
|
||||
svg: TemplateResult<1>;
|
||||
}
|
||||
|
||||
interface Coord {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
type TransformMap = Record<
|
||||
string,
|
||||
{
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
origin: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export class EdgelessShapeToolElement extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.shape {
|
||||
--x: 0px;
|
||||
--y: 0px;
|
||||
--offset-x: 0px;
|
||||
--offset-y: 0px;
|
||||
--scale: 1;
|
||||
transform: translateX(calc(var(--offset-x) + var(--x)))
|
||||
translateY(calc(var(--y) + var(--offset-y))) scale(var(--scale));
|
||||
height: 60px;
|
||||
width: 60px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 16px;
|
||||
transition: all 0.5s cubic-bezier(0, -0.01, 0.01, 1.01);
|
||||
}
|
||||
.shape.dragging {
|
||||
transition: none;
|
||||
}
|
||||
.shape svg {
|
||||
height: 100%;
|
||||
filter: drop-shadow(0px 2px 8px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
`;
|
||||
|
||||
private _addShape = (coord: Coord, padding: Coord) => {
|
||||
const width = 100;
|
||||
const height = 100;
|
||||
const { x: edgelessX, y: edgelessY } =
|
||||
this.edgeless.getBoundingClientRect();
|
||||
const zoom = this.edgeless.service.viewport.zoom;
|
||||
const [modelX, modelY] = this.edgeless.service.viewport.toModelCoord(
|
||||
coord.x - edgelessX - width * padding.x * zoom,
|
||||
coord.y - edgelessY - height * padding.y * zoom
|
||||
);
|
||||
const xywh = new Bound(modelX, modelY, width, height).serialize();
|
||||
this.edgeless.service.addElement(CanvasElementType.SHAPE, {
|
||||
shapeType: getShapeType(this.shape.name),
|
||||
xywh: xywh,
|
||||
radius: getShapeRadius(this.shape.name),
|
||||
});
|
||||
};
|
||||
|
||||
private _onDragEnd = async (coord: Coord) => {
|
||||
if (this._startCoord.x === coord.x && this._startCoord.y === coord.y) {
|
||||
this.handleClick();
|
||||
this._dragging = false;
|
||||
return;
|
||||
}
|
||||
if (!this._dragging) {
|
||||
return;
|
||||
}
|
||||
this._dragging = false;
|
||||
this.edgeless.gfx.tool.setTool('default');
|
||||
if (this._isOutside) {
|
||||
const rect = this._shapeElement.getBoundingClientRect();
|
||||
this._backupShapeElement.style.setProperty('transition', 'none');
|
||||
this._backupShapeElement.style.setProperty('--y', '100px');
|
||||
this._shapeElement.style.setProperty('--offset-x', `${0}px`);
|
||||
this._shapeElement.style.setProperty('--offset-y', `${0}px`);
|
||||
await sleep(0);
|
||||
this._shapeElement.classList.remove('dragging');
|
||||
this._backupShapeElement.style.removeProperty('transition');
|
||||
const padding = {
|
||||
x: (coord.x - rect.left) / rect.width,
|
||||
y: (coord.y - rect.top) / rect.height,
|
||||
};
|
||||
this._addShape(coord, padding);
|
||||
} else {
|
||||
this._shapeElement.classList.remove('dragging');
|
||||
this._shapeElement.style.setProperty('--offset-x', `${0}px`);
|
||||
this._shapeElement.style.setProperty('--offset-y', `${0}px`);
|
||||
this._backupShapeElement.style.setProperty('--y', '100px');
|
||||
}
|
||||
};
|
||||
|
||||
private _onDragMove = (coord: Coord) => {
|
||||
if (!this._dragging) {
|
||||
return;
|
||||
}
|
||||
const controller = this.edgeless.gfx.tool.currentTool$.peek();
|
||||
if (controller instanceof ShapeTool) {
|
||||
controller.clearOverlay();
|
||||
}
|
||||
const { x, y } = coord;
|
||||
this._shapeElement.style.setProperty(
|
||||
'--offset-x',
|
||||
`${x - this._startCoord.x}px`
|
||||
);
|
||||
this._shapeElement.style.setProperty(
|
||||
'--offset-y',
|
||||
`${y - this._startCoord.y}px`
|
||||
);
|
||||
const containerRect = this.getContainerRect();
|
||||
const isOut =
|
||||
y < containerRect.top ||
|
||||
x < containerRect.left ||
|
||||
x > containerRect.right;
|
||||
if (isOut !== this._isOutside) {
|
||||
this._backupShapeElement.style.setProperty(
|
||||
'--y',
|
||||
isOut ? '5px' : '100px'
|
||||
);
|
||||
this._backupShapeElement.style.setProperty(
|
||||
'--scale',
|
||||
isOut ? '1' : '0.9'
|
||||
);
|
||||
}
|
||||
this._isOutside = isOut;
|
||||
};
|
||||
|
||||
private _onDragStart = (coord: Coord) => {
|
||||
this._startCoord = { x: coord.x, y: coord.y };
|
||||
if (this.order !== 1) {
|
||||
return;
|
||||
}
|
||||
this._dragging = true;
|
||||
this._shapeElement.classList.add('dragging');
|
||||
};
|
||||
|
||||
private _onMouseMove = (event: MouseEvent) => {
|
||||
if (!this._dragging) {
|
||||
return;
|
||||
}
|
||||
this._onDragMove({ x: event.clientX, y: event.clientY });
|
||||
};
|
||||
|
||||
private _onMouseUp = (event: MouseEvent) => {
|
||||
this._onDragEnd({ x: event.clientX, y: event.clientY }).catch(
|
||||
console.error
|
||||
);
|
||||
};
|
||||
|
||||
private _onTouchEnd = (event: TouchEvent) => {
|
||||
if (!event.changedTouches.length) return;
|
||||
|
||||
this._onDragEnd({
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent#touchend
|
||||
x: event.changedTouches[0].clientX,
|
||||
y: event.changedTouches[0].clientY,
|
||||
}).catch(console.error);
|
||||
};
|
||||
|
||||
private _touchMove = (event: TouchEvent) => {
|
||||
if (!this._dragging) {
|
||||
return;
|
||||
}
|
||||
this._onDragMove({
|
||||
x: event.touches[0].clientX,
|
||||
y: event.touches[0].clientY,
|
||||
});
|
||||
};
|
||||
|
||||
private _transformMap: TransformMap = {
|
||||
z1: { x: 0, y: 5, scale: 1.1, origin: '50% 100%' },
|
||||
z2: { x: -15, y: 0, scale: 0.75, origin: '20% 20%' },
|
||||
z3: { x: 15, y: 0, scale: 0.75, origin: '80% 20%' },
|
||||
hidden: { x: 0, y: 120, scale: 0, origin: '50% 50%' },
|
||||
};
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._disposables.addFromEvent(
|
||||
this.edgeless.host,
|
||||
'mousemove',
|
||||
this._onMouseMove
|
||||
);
|
||||
this._disposables.addFromEvent(
|
||||
this.edgeless.host,
|
||||
'touchmove',
|
||||
this._touchMove
|
||||
);
|
||||
this._disposables.addFromEvent(
|
||||
this.edgeless.host,
|
||||
'mouseup',
|
||||
this._onMouseUp
|
||||
);
|
||||
this._disposables.addFromEvent(
|
||||
this.edgeless.host,
|
||||
'touchend',
|
||||
this._onTouchEnd
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div
|
||||
id="shape-tool-element"
|
||||
class="shape"
|
||||
@mousedown=${(event: MouseEvent) =>
|
||||
this._onDragStart({ x: event.clientX, y: event.clientY })}
|
||||
@touchstart=${(event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
this._onDragStart({
|
||||
x: event.touches[0].clientX,
|
||||
y: event.touches[0].clientY,
|
||||
});
|
||||
}}
|
||||
>
|
||||
${this.shape.svg}
|
||||
</div>
|
||||
${this.order === 1
|
||||
? html`<div id="backup-shape-element" class="shape">
|
||||
${this.shape.svg}
|
||||
</div>`
|
||||
: null}
|
||||
`;
|
||||
}
|
||||
|
||||
override updated(changedProperties: PropertyValues<this>) {
|
||||
if (!changedProperties.has('shape') && !changedProperties.has('order')) {
|
||||
return;
|
||||
}
|
||||
const transform =
|
||||
this._transformMap[this.order <= 3 ? `z${this.order}` : 'hidden'];
|
||||
this._shapeElement.style.setProperty('--x', `${transform.x}px`);
|
||||
this._shapeElement.style.setProperty('--y', `${transform.y}px`);
|
||||
this._shapeElement.style.setProperty(
|
||||
'--scale',
|
||||
String(transform.scale || 1)
|
||||
);
|
||||
this._shapeElement.style.zIndex = String(999 - this.order);
|
||||
this._shapeElement.style.transformOrigin = transform.origin;
|
||||
|
||||
if (this._backupShapeElement) {
|
||||
this._backupShapeElement.style.setProperty('--y', '100px');
|
||||
this._backupShapeElement.style.setProperty('--scale', '0.9');
|
||||
this._backupShapeElement.style.zIndex = '999';
|
||||
}
|
||||
}
|
||||
|
||||
@query('#backup-shape-element')
|
||||
private accessor _backupShapeElement!: HTMLElement;
|
||||
|
||||
@state()
|
||||
private accessor _dragging: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor _isOutside: boolean = false;
|
||||
|
||||
@query('#shape-tool-element')
|
||||
private accessor _shapeElement!: HTMLElement;
|
||||
|
||||
@state()
|
||||
private accessor _startCoord: Coord = { x: -1, y: -1 };
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor getContainerRect!: () => DOMRect;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor handleClick!: () => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor order!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor shape!: Shape;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor shapeStyle!: ShapeStyle;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor shapeType!: ShapeName;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-shape-tool-element': EdgelessShapeToolElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { render, type TemplateResult } from 'lit';
|
||||
|
||||
import type { ShapeToolOption } from '../../../gfx-tool/shape-tool.js';
|
||||
|
||||
type TransformState = {
|
||||
/** horizental offset base on center */
|
||||
x?: number | string;
|
||||
/** vertical offset base on center */
|
||||
y?: number | string;
|
||||
/** scale */
|
||||
s?: number;
|
||||
/** z-index */
|
||||
z?: number;
|
||||
};
|
||||
|
||||
export type DraggableShape = {
|
||||
name: ShapeToolOption['shapeName'];
|
||||
svg: TemplateResult;
|
||||
style: {
|
||||
default?: TransformState;
|
||||
hover?: TransformState;
|
||||
/**
|
||||
* The next shape when previous shape is dragged outside toolbar
|
||||
*/
|
||||
next?: TransformState;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to build the CSS variables object for the shape
|
||||
* @returns
|
||||
*/
|
||||
export const buildVariablesObject = (style: DraggableShape['style']) => {
|
||||
const states: Array<keyof DraggableShape['style']> = [
|
||||
'default',
|
||||
'hover',
|
||||
'next',
|
||||
];
|
||||
const variables: Array<keyof TransformState> = ['x', 'y', 's', 'z'];
|
||||
|
||||
const resolveValue = (
|
||||
variable: keyof TransformState,
|
||||
value: string | number
|
||||
) => {
|
||||
if (['x', 'y'].includes(variable)) {
|
||||
return typeof value === 'number' ? `${value}px` : value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return states.reduce((acc, state) => {
|
||||
return {
|
||||
...acc,
|
||||
...variables.reduce((acc, variable) => {
|
||||
const defaultValue = style.default?.[variable];
|
||||
const value = style[state]?.[variable] ?? defaultValue;
|
||||
if (value === undefined) return acc;
|
||||
return {
|
||||
...acc,
|
||||
[`--${state}-${variable}`]: resolveValue(variable, value),
|
||||
};
|
||||
}, {}),
|
||||
};
|
||||
}, {});
|
||||
};
|
||||
|
||||
// drag helper
|
||||
export type ShapeDragEvent = {
|
||||
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 ShapeDragEvent;
|
||||
|
||||
export const mouseResolver = (event: MouseEvent) =>
|
||||
({
|
||||
inputType: 'mouse',
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
el: event.currentTarget as HTMLElement,
|
||||
originalEvent: event,
|
||||
}) satisfies ShapeDragEvent;
|
||||
|
||||
// overlay helper
|
||||
export const defaultDraggingInfo = {
|
||||
startPos: { x: 0, y: 0 },
|
||||
toolbarRect: {} as DOMRect,
|
||||
edgelessRect: {} as DOMRect,
|
||||
shapeRectOriginal: {} as DOMRect,
|
||||
shapeEl: null as unknown as HTMLElement,
|
||||
parentToMount: null as unknown as HTMLElement,
|
||||
moved: false,
|
||||
shape: null as unknown as DraggableShape,
|
||||
style: {} as CSSStyleDeclaration,
|
||||
};
|
||||
export type DraggingInfo = typeof defaultDraggingInfo;
|
||||
|
||||
export const createShapeDraggingOverlay = (info: DraggingInfo) => {
|
||||
const { edgelessRect, parentToMount } = info;
|
||||
const overlay = document.createElement('div');
|
||||
Object.assign(overlay.style, {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: edgelessRect.width + 'px',
|
||||
// always clip
|
||||
// height: toolbarRect.bottom - edgelessRect.top + 'px',
|
||||
height: edgelessRect.height + 'px',
|
||||
overflow: 'hidden',
|
||||
zIndex: '9999',
|
||||
|
||||
// for debug purpose
|
||||
// background: 'rgba(255, 0, 0, 0.1)',
|
||||
});
|
||||
|
||||
const shape = document.createElement('div');
|
||||
const shapeScaleWrapper = document.createElement('div');
|
||||
Object.assign(shapeScaleWrapper.style, {
|
||||
transform: 'scale(var(--s, 1))',
|
||||
transition: 'transform 0.1s',
|
||||
transformOrigin: 'var(--o, center)',
|
||||
});
|
||||
render(info.shape.svg, shapeScaleWrapper);
|
||||
Object.assign(shape.style, {
|
||||
position: 'absolute',
|
||||
color: info.style.color,
|
||||
stroke: info.style.stroke,
|
||||
filter: `var(--shape-filter, ${info.style.filter})`,
|
||||
transform: 'translate(var(--x, 0), var(--y, 0))',
|
||||
left: 'var(--left, 0)',
|
||||
top: 'var(--top, 0)',
|
||||
cursor: 'grabbing',
|
||||
transition: 'inherit',
|
||||
});
|
||||
|
||||
shape.append(shapeScaleWrapper);
|
||||
overlay.append(shape);
|
||||
parentToMount.append(overlay);
|
||||
|
||||
return overlay;
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { keys } from '@blocksuite/global/utils';
|
||||
|
||||
import type {
|
||||
Template,
|
||||
TemplateCategory,
|
||||
TemplateManager,
|
||||
} from './template-type.js';
|
||||
|
||||
export const templates: TemplateCategory[] = [];
|
||||
|
||||
function lcs(text1: string, text2: string) {
|
||||
const dp: number[][] = Array.from(
|
||||
{
|
||||
length: text1.length + 1,
|
||||
},
|
||||
() => Array.from({ length: text2.length + 1 }, () => 0)
|
||||
);
|
||||
|
||||
for (let i = 1; i <= text1.length; i++) {
|
||||
for (let j = 1; j <= text2.length; j++) {
|
||||
if (text1[i - 1] === text2[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dp[text1.length][text2.length];
|
||||
}
|
||||
const extendTemplate: TemplateManager[] = [];
|
||||
|
||||
const flat = <T>(arr: T[][]) =>
|
||||
arr.reduce((pre, current) => {
|
||||
if (current) {
|
||||
return pre.concat(current);
|
||||
}
|
||||
|
||||
return pre;
|
||||
}, []);
|
||||
|
||||
export const builtInTemplates = {
|
||||
list: async (category: string) => {
|
||||
const extendTemplates = flat(
|
||||
await Promise.all(extendTemplate.map(manager => manager.list(category)))
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-empty-collection
|
||||
const cate = templates.find(cate => cate.name === category);
|
||||
if (!cate) return extendTemplates;
|
||||
|
||||
const result: Template[] =
|
||||
cate.templates instanceof Function
|
||||
? await cate.templates()
|
||||
: await Promise.all(
|
||||
// @ts-expect-error FIXME: ts error
|
||||
keys(cate.templates).map(key => cate.templates[key]())
|
||||
);
|
||||
|
||||
return result.concat(extendTemplates);
|
||||
},
|
||||
|
||||
categories: async () => {
|
||||
const extendCates = flat(
|
||||
await Promise.all(extendTemplate.map(manager => manager.categories()))
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-empty-collection
|
||||
return templates.map(cate => cate.name).concat(extendCates);
|
||||
},
|
||||
|
||||
search: async (keyword: string, cateName?: string) => {
|
||||
const candidates: Template[] = flat(
|
||||
await Promise.all(
|
||||
extendTemplate.map(manager => manager.search(keyword, cateName))
|
||||
)
|
||||
);
|
||||
|
||||
keyword = keyword.trim().toLocaleLowerCase();
|
||||
|
||||
await Promise.all(
|
||||
// eslint-disable-next-line sonarjs/no-empty-collection
|
||||
templates.map(async categroy => {
|
||||
if (cateName && cateName !== categroy.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (categroy.templates instanceof Function) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
keys(categroy.templates).map(async name => {
|
||||
if (
|
||||
lcs(keyword, (name as string).toLocaleLowerCase()) ===
|
||||
keyword.length
|
||||
) {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
const template = await categroy.templates[name]();
|
||||
|
||||
candidates.push(template);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return candidates;
|
||||
},
|
||||
|
||||
extend(manager: TemplateManager) {
|
||||
if (extendTemplate.includes(manager)) return;
|
||||
|
||||
extendTemplate.push(manager);
|
||||
},
|
||||
} satisfies TemplateManager;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {
|
||||
on,
|
||||
once,
|
||||
requestConnectedFrame,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
|
||||
/**
|
||||
* A scrollbar that is only visible when the user is interacting with it.
|
||||
* Append this element to the a container that has a scrollable element. Which means
|
||||
* the scrollable element should lay on the same level as the overlay-scrollbar.
|
||||
*
|
||||
* And the scrollable element should have a `data-scrollable` attribute.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* <div class="container">
|
||||
* <div class="scrollable-element-with-fixed-height" data-scrollable>
|
||||
* <!--.... very long content ....-->
|
||||
* </div>
|
||||
* <overlay-scrollbar></overlay-scrollbar>
|
||||
* </div>
|
||||
* ```
|
||||
*
|
||||
* Note:
|
||||
* - It only works with vertical scrollbars.
|
||||
*/
|
||||
export class OverlayScrollbar extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.overlay-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 2px;
|
||||
background-color: rgba(0, 0, 0, 0.44);
|
||||
border-radius: 3px;
|
||||
width: 6px;
|
||||
}
|
||||
`;
|
||||
|
||||
private _disposable = new DisposableGroup();
|
||||
|
||||
private _handleVisible = false;
|
||||
|
||||
private _scrollable: HTMLElement | null = null;
|
||||
|
||||
private _dragHandle(event: PointerEvent) {
|
||||
let startY = event.clientY;
|
||||
|
||||
this._handleVisible = true;
|
||||
|
||||
const dispose = on(document, 'pointermove', evt => {
|
||||
this._scroll(evt.clientY - startY);
|
||||
startY = evt.clientY;
|
||||
});
|
||||
|
||||
once(document, 'pointerup', e => {
|
||||
this._handleVisible = false;
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
setTimeout(() => {
|
||||
this._toggleScrollbarVisible(false);
|
||||
}, 800);
|
||||
|
||||
dispose();
|
||||
});
|
||||
}
|
||||
|
||||
private _initWheelHandler() {
|
||||
const container = this.parentElement as HTMLElement;
|
||||
|
||||
container.style.contain = 'layout';
|
||||
container.style.overflow = 'hidden';
|
||||
|
||||
let hideScrollbarTimeId: null | ReturnType<typeof setTimeout> = null;
|
||||
const delayHideScrollbar = () => {
|
||||
if (hideScrollbarTimeId) clearTimeout(hideScrollbarTimeId);
|
||||
hideScrollbarTimeId = setTimeout(() => {
|
||||
this._toggleScrollbarVisible(false);
|
||||
hideScrollbarTimeId = null;
|
||||
}, 800);
|
||||
};
|
||||
|
||||
let scrollable: HTMLElement | null = null;
|
||||
this._disposable.addFromEvent(container, 'wheel', event => {
|
||||
scrollable = scrollable?.isConnected
|
||||
? scrollable
|
||||
: (container.querySelector('[data-scrollable]') as HTMLElement);
|
||||
|
||||
this._scrollable = scrollable;
|
||||
|
||||
if (!scrollable) return;
|
||||
|
||||
// firefox may report a wheel event with deltaMode of value other than 0
|
||||
// we just simply multiply it by 16 which is common default line height to get the correct value
|
||||
const scrollDistance =
|
||||
event.deltaMode === 0 ? event.deltaY : event.deltaY * 16;
|
||||
|
||||
this._scroll(scrollDistance ?? 0);
|
||||
|
||||
delayHideScrollbar();
|
||||
});
|
||||
}
|
||||
|
||||
private _scroll(scrollDistance: number) {
|
||||
const scrollable = this._scrollable!;
|
||||
|
||||
if (!scrollable) return;
|
||||
|
||||
scrollable.scrollBy({
|
||||
left: 0,
|
||||
top: scrollDistance,
|
||||
behavior: 'instant',
|
||||
});
|
||||
|
||||
requestConnectedFrame(() => {
|
||||
this._updateScrollbarRect(scrollable);
|
||||
this._toggleScrollbarVisible(true);
|
||||
}, this);
|
||||
}
|
||||
|
||||
private _toggleScrollbarVisible(visible: boolean) {
|
||||
const vis = visible || this._handleVisible ? '1' : '0';
|
||||
|
||||
if (this.style.opacity !== vis) {
|
||||
this.style.opacity = vis;
|
||||
}
|
||||
}
|
||||
|
||||
private _updateScrollbarRect(rect: {
|
||||
scrollTop?: number;
|
||||
clientHeight?: number;
|
||||
scrollHeight?: number;
|
||||
}) {
|
||||
if (rect.scrollHeight !== undefined && rect.clientHeight !== undefined) {
|
||||
this._handle.style.height = `${(rect.clientHeight / rect.scrollHeight) * 100}%`;
|
||||
}
|
||||
|
||||
if (rect.scrollTop !== undefined && rect.scrollHeight !== undefined) {
|
||||
this._handle.style.top = `${(rect.scrollTop / rect.scrollHeight) * 100}%`;
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._disposable.dispose();
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
this._initWheelHandler();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<div
|
||||
class="overlay-handle"
|
||||
@pointerdown=${this._dragHandle}
|
||||
></div>`;
|
||||
}
|
||||
|
||||
@query('.overlay-handle')
|
||||
private accessor _handle!: HTMLDivElement;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'overlay-scrollbar': OverlayScrollbar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { css, html, LitElement } from 'lit';
|
||||
|
||||
export class AffineTemplateLoading extends LitElement {
|
||||
static override styles = css`
|
||||
@keyframes affine-template-block-rotate {
|
||||
from {
|
||||
rotate: 0deg;
|
||||
}
|
||||
to {
|
||||
rotate: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
.affine-template-block-container {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.affine-template-block-loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
background: conic-gradient(
|
||||
rgba(30, 150, 235, 1) 90deg,
|
||||
rgba(0, 0, 0, 0.1) 90deg 360deg
|
||||
);
|
||||
border-radius: 50%;
|
||||
animation: affine-template-block-rotate 1s infinite ease-in;
|
||||
}
|
||||
|
||||
.affine-template-block-loading::before {
|
||||
content: '';
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return html`<div class="affine-template-block-container">
|
||||
<div class="affine-template-block-loading"></div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-template-loading': AffineTemplateLoading;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import {
|
||||
darkToolbarStyles,
|
||||
lightToolbarStyles,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
EditPropsStore,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
requestConnectedFrame,
|
||||
stopPropagation,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { type Bound, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
|
||||
|
||||
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
|
||||
import { EdgelessDraggableElementController } from '../common/draggable/draggable-element.controller.js';
|
||||
import { builtInTemplates } from './builtin-templates.js';
|
||||
import { ArrowIcon, defaultPreview } from './icon.js';
|
||||
import type { Template } from './template-type.js';
|
||||
import { cloneDeep } from './utils.js';
|
||||
|
||||
export class EdgelessTemplatePanel extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.edgeless-templates-panel {
|
||||
width: 467px;
|
||||
height: 568px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: 0px 10px 80px 0px rgba(0, 0, 0, 0.2);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.edgeless-templates-panel[data-app-theme='light'] {
|
||||
${unsafeCSS(lightToolbarStyles.join('\n'))}
|
||||
}
|
||||
.edgeless-templates-panel[data-app-theme='dark'] {
|
||||
${unsafeCSS(darkToolbarStyles.join('\n'))}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
padding: 21px 24px;
|
||||
font-size: 18px;
|
||||
color: var(--affine-secondary);
|
||||
border-bottom: 1px solid var(--affine-divider-color);
|
||||
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
border: 0;
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 20px;
|
||||
background-color: inherit;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.template-categories {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
gap: 4px;
|
||||
overflow-x: scroll;
|
||||
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-entry {
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
width: fit-content;
|
||||
padding: 4px 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-entry.selected,
|
||||
.category-entry:hover {
|
||||
color: var(--affine-text-primary-color);
|
||||
background-color: var(--affine-background-tertiary-color);
|
||||
}
|
||||
|
||||
.template-viewport {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.template-scrollcontent {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.template-list {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-content: flex-start;
|
||||
gap: 10px 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.template-item {
|
||||
position: relative;
|
||||
width: 135px;
|
||||
height: 80px;
|
||||
box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.02);
|
||||
background-color: var(--affine-background-primary-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.template-item > svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
width: 135px;
|
||||
height: 80px;
|
||||
color: var(--affine-background-primary-color);
|
||||
}
|
||||
|
||||
/* .template-item:hover::before {
|
||||
content: attr(data-hover-text);
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 110px;
|
||||
border-radius: 8px;
|
||||
padding: 4px 22px;
|
||||
box-sizing: border-box;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
|
||||
background-color: var(--affine-primary-color);
|
||||
color: var(--affine-white);
|
||||
} */
|
||||
|
||||
.template-item:hover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--affine-black-10);
|
||||
border-radius: 4px;
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.template-item.loading::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.template-item.loading > affine-template-loading {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.template-item img.template-preview {
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
transform: translateY(20px);
|
||||
color: var(--affine-background-overlay-panel-color);
|
||||
}
|
||||
`;
|
||||
|
||||
static templates = builtInTemplates;
|
||||
|
||||
private _fetchJob: null | { cancel: () => void } = null;
|
||||
|
||||
draggableController!: EdgelessDraggableElementController<Template>;
|
||||
|
||||
private _closePanel() {
|
||||
if (this.isDragging) return;
|
||||
this.dispatchEvent(new CustomEvent('closepanel'));
|
||||
}
|
||||
|
||||
private _fetch(fn: (state: { canceled: boolean }) => Promise<unknown>) {
|
||||
if (this._fetchJob) {
|
||||
this._fetchJob.cancel();
|
||||
}
|
||||
|
||||
this._loading = true;
|
||||
|
||||
const state = { canceled: false };
|
||||
const job = {
|
||||
cancel: () => {
|
||||
state.canceled = true;
|
||||
},
|
||||
};
|
||||
|
||||
this._fetchJob = job;
|
||||
|
||||
fn(state)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!state.canceled && job === this._fetchJob) {
|
||||
this._loading = false;
|
||||
this._fetchJob = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _getLocalSelectedCategory() {
|
||||
return this.edgeless.std.get(EditPropsStore).getStorage('templateCache');
|
||||
}
|
||||
|
||||
private async _initCategory() {
|
||||
try {
|
||||
this._categories = await EdgelessTemplatePanel.templates.categories();
|
||||
this._currentCategory =
|
||||
this._getLocalSelectedCategory() ?? this._categories[0];
|
||||
this._updateTemplates();
|
||||
} catch (e) {
|
||||
console.error('Failed to load categories', e);
|
||||
}
|
||||
}
|
||||
|
||||
private _initDragController() {
|
||||
if (this.draggableController) return;
|
||||
this.draggableController = new EdgelessDraggableElementController(this, {
|
||||
service: this.edgeless.service,
|
||||
edgeless: this.edgeless,
|
||||
clickToDrag: true,
|
||||
standardWidth: 560,
|
||||
onOverlayCreated: overlay => {
|
||||
this.isDragging = true;
|
||||
overlay.mask.style.color = 'transparent';
|
||||
},
|
||||
onDrop: (el, bound) => {
|
||||
this._insertTemplate(el.data, bound)
|
||||
.finally(() => {
|
||||
this.isDragging = false;
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
onCanceled: () => {
|
||||
this.isDragging = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async _insertTemplate(template: Template, bound: Bound) {
|
||||
this._loadingTemplate = template;
|
||||
|
||||
template = cloneDeep(template);
|
||||
|
||||
const center = {
|
||||
x: bound.x + bound.w / 2,
|
||||
y: bound.y + bound.h / 2,
|
||||
};
|
||||
const templateJob = this.edgeless.service.createTemplateJob(
|
||||
template.type,
|
||||
center
|
||||
);
|
||||
const service = this.edgeless.service;
|
||||
|
||||
try {
|
||||
const { assets } = template;
|
||||
|
||||
if (assets) {
|
||||
await Promise.all(
|
||||
Object.entries(assets).map(([key, value]) =>
|
||||
fetch(value)
|
||||
.then(res => res.blob())
|
||||
.then(blob => templateJob.job.assets.set(key, blob))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const insertedBound = await templateJob.insertTemplate(template.content);
|
||||
|
||||
if (insertedBound && template.type === 'template') {
|
||||
const padding = 20 / service.viewport.zoom;
|
||||
service.viewport.setViewportByBound(
|
||||
insertedBound,
|
||||
[padding, padding, padding, padding],
|
||||
true
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this._loadingTemplate = null;
|
||||
this.edgeless.gfx.tool.setTool('default');
|
||||
}
|
||||
}
|
||||
|
||||
private _updateSearchKeyword(inputEvt: InputEvent) {
|
||||
this._searchKeyword = (inputEvt.target as HTMLInputElement).value;
|
||||
this._updateTemplates();
|
||||
}
|
||||
|
||||
private _updateTemplates() {
|
||||
this._fetch(async state => {
|
||||
try {
|
||||
const templates = this._searchKeyword
|
||||
? await EdgelessTemplatePanel.templates.search(this._searchKeyword)
|
||||
: await EdgelessTemplatePanel.templates.list(this._currentCategory);
|
||||
|
||||
if (state.canceled) return;
|
||||
|
||||
this._templates = templates;
|
||||
} catch (e) {
|
||||
if (state.canceled) return;
|
||||
|
||||
console.error('Failed to load templates', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._initDragController();
|
||||
|
||||
this.addEventListener('keydown', stopPropagation, false);
|
||||
this._disposables.add(() => {
|
||||
if (this._currentCategory) {
|
||||
this.edgeless.std
|
||||
.get(EditPropsStore)
|
||||
.setStorage('templateCache', this._currentCategory);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
requestConnectedFrame(() => {
|
||||
this._disposables.addFromEvent(document, 'click', evt => {
|
||||
if (this.contains(evt.target as HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._closePanel();
|
||||
});
|
||||
}, this);
|
||||
this._disposables.addFromEvent(this, 'click', stopPropagation);
|
||||
this._disposables.addFromEvent(this, 'wheel', stopPropagation);
|
||||
|
||||
this._initCategory().catch(() => {});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { _categories, _currentCategory, _templates } = this;
|
||||
const { draggingElement } = this.draggableController?.states || {};
|
||||
const appTheme = this.edgeless.std.get(ThemeProvider).app$.value;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="edgeless-templates-panel"
|
||||
data-app-theme=${appTheme}
|
||||
style=${styleMap({
|
||||
opacity: this.isDragging ? '0' : '1',
|
||||
transition: 'opacity 0.2s',
|
||||
})}
|
||||
>
|
||||
<div class="search-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
placeholder="Search file or anything..."
|
||||
@input=${this._updateSearchKeyword}
|
||||
@cut=${stopPropagation}
|
||||
@copy=${stopPropagation}
|
||||
@paste=${stopPropagation}
|
||||
/>
|
||||
</div>
|
||||
<div class="template-categories">
|
||||
${repeat(
|
||||
_categories,
|
||||
cate => cate,
|
||||
cate => {
|
||||
return html`<div
|
||||
class="category-entry ${_currentCategory === cate
|
||||
? 'selected'
|
||||
: ''}"
|
||||
@click=${() => {
|
||||
this._currentCategory = cate;
|
||||
this._updateTemplates();
|
||||
}}
|
||||
>
|
||||
${cate}
|
||||
</div>`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div class="template-viewport">
|
||||
<div class="template-scrollcontent" data-scrollable>
|
||||
<div class="template-list">
|
||||
${this._loading
|
||||
? html`<affine-template-loading
|
||||
style=${styleMap({
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
})}
|
||||
></affine-template-loading>`
|
||||
: repeat(
|
||||
_templates,
|
||||
template => template.name,
|
||||
template => {
|
||||
const preview = template.preview
|
||||
? template.preview.startsWith('<svg')
|
||||
? html`${unsafeSVG(template.preview)}`
|
||||
: html`<img
|
||||
src="${template.preview}"
|
||||
class="template-preview"
|
||||
loading="lazy"
|
||||
/>`
|
||||
: defaultPreview;
|
||||
|
||||
const isBeingDragged =
|
||||
draggingElement &&
|
||||
draggingElement.data.name === template.name;
|
||||
return html`
|
||||
<div
|
||||
class=${`template-item ${
|
||||
template === this._loadingTemplate ? 'loading' : ''
|
||||
}`}
|
||||
style=${styleMap({
|
||||
opacity: isBeingDragged ? '0' : '1',
|
||||
})}
|
||||
data-hover-text="Add"
|
||||
@mousedown=${(e: MouseEvent) =>
|
||||
this.draggableController.onMouseDown(e, {
|
||||
data: template,
|
||||
preview,
|
||||
})}
|
||||
@touchstart=${(e: TouchEvent) => {
|
||||
this.draggableController.onTouchStart(e, {
|
||||
data: template,
|
||||
preview,
|
||||
});
|
||||
}}
|
||||
>
|
||||
${preview}
|
||||
${template === this._loadingTemplate
|
||||
? html`<affine-template-loading></affine-template-loading>`
|
||||
: nothing}
|
||||
${template.name
|
||||
? html`<affine-tooltip
|
||||
.offset=${12}
|
||||
tip-position="top"
|
||||
>
|
||||
${template.name}
|
||||
</affine-tooltip>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<overlay-scrollbar></overlay-scrollbar>
|
||||
</div>
|
||||
<div class="arrow">${ArrowIcon}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _categories: string[] = [];
|
||||
|
||||
@state()
|
||||
private accessor _currentCategory = '';
|
||||
|
||||
@state()
|
||||
private accessor _loading = false;
|
||||
|
||||
@state()
|
||||
private accessor _loadingTemplate: Template | null = null;
|
||||
|
||||
@state()
|
||||
private accessor _searchKeyword = '';
|
||||
|
||||
@state()
|
||||
private accessor _templates: Template[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
@state()
|
||||
accessor isDragging = false;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-templates-panel': EdgelessTemplatePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { ArrowDownSmallIcon } from '@blocksuite/affine-components/icons';
|
||||
import { once } from '@blocksuite/affine-shared/utils';
|
||||
import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
arrow,
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
offset,
|
||||
shift,
|
||||
} from '@floating-ui/dom';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js';
|
||||
import { TemplateCard1, TemplateCard2, TemplateCard3 } from './icon.js';
|
||||
import type { EdgelessTemplatePanel } from './template-panel.js';
|
||||
|
||||
export class EdgelessTemplateButton extends EdgelessToolbarToolMixin(
|
||||
LitElement
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
edgeless-template-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.template-cards {
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
.template-card,
|
||||
.arrow-icon {
|
||||
--x: 0;
|
||||
--y: 0;
|
||||
--r: 0;
|
||||
--s: 1;
|
||||
position: absolute;
|
||||
transform: translate(var(--x), var(--y)) rotate(var(--r)) scale(var(--s));
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
--y: 17px;
|
||||
background: var(--affine-black-10);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.arrow-icon > svg {
|
||||
color: var(--affine-icon-color);
|
||||
fill: currentColor;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.template-card.card1 {
|
||||
transform-origin: 100% 50%;
|
||||
--x: 15px;
|
||||
--y: 8px;
|
||||
}
|
||||
.template-card.card2 {
|
||||
transform-origin: 0% 50%;
|
||||
--x: -17px;
|
||||
}
|
||||
.template-card.card3 {
|
||||
--y: 27px;
|
||||
}
|
||||
|
||||
/* hover */
|
||||
.template-cards:not(.expanded):hover .card1 {
|
||||
--r: 8.69deg;
|
||||
}
|
||||
.template-cards:not(.expanded):hover .card2 {
|
||||
--r: -10.93deg;
|
||||
}
|
||||
.template-cards:not(.expanded):hover .card3 {
|
||||
--y: 22px;
|
||||
--r: 5.19deg;
|
||||
}
|
||||
|
||||
/* expanded */
|
||||
.template-cards.expanded .card1 {
|
||||
--x: 17px;
|
||||
--y: -5px;
|
||||
--r: 8.69deg;
|
||||
--s: 0.64;
|
||||
}
|
||||
.template-cards.expanded .card2 {
|
||||
--x: -19px;
|
||||
--y: -6px;
|
||||
--r: -10.93deg;
|
||||
--s: 0.64;
|
||||
}
|
||||
.template-cards.expanded .card3 {
|
||||
--y: -10px;
|
||||
--s: 0.599;
|
||||
--r: 5.19deg;
|
||||
}
|
||||
`;
|
||||
|
||||
private _cleanup: (() => void) | null = null;
|
||||
|
||||
private _prevTool: GfxToolsFullOptionValue | null = null;
|
||||
|
||||
override enableActiveBackground = true;
|
||||
|
||||
override type: GfxToolsFullOptionValue['type'] = 'template';
|
||||
|
||||
get cards() {
|
||||
const { theme } = this;
|
||||
return [TemplateCard1[theme], TemplateCard2[theme], TemplateCard3[theme]];
|
||||
}
|
||||
|
||||
private _closePanel() {
|
||||
if (this._openedPanel) {
|
||||
this._openedPanel.remove();
|
||||
this._openedPanel = null;
|
||||
this._cleanup?.();
|
||||
this._cleanup = null;
|
||||
this.requestUpdate();
|
||||
|
||||
if (this._prevTool && this._prevTool.type !== 'template') {
|
||||
this.setEdgelessTool(this._prevTool);
|
||||
this._prevTool = null;
|
||||
} else {
|
||||
this.setEdgelessTool('default');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _togglePanel() {
|
||||
if (this._openedPanel) {
|
||||
this._closePanel();
|
||||
if (this._prevTool) {
|
||||
this.setEdgelessTool(this._prevTool);
|
||||
this._prevTool = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._prevTool = this.edgelessTool ? { ...this.edgelessTool } : null;
|
||||
|
||||
this.setEdgelessTool('template');
|
||||
|
||||
const panel = document.createElement('edgeless-templates-panel');
|
||||
panel.edgeless = this.edgeless;
|
||||
|
||||
this._cleanup = once(panel, 'closepanel', () => {
|
||||
this._closePanel();
|
||||
});
|
||||
this._openedPanel = panel;
|
||||
|
||||
this.renderRoot.append(panel);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const arrowEl = panel.renderRoot.querySelector('.arrow') as HTMLElement;
|
||||
|
||||
autoUpdate(this, panel, () => {
|
||||
computePosition(this, panel, {
|
||||
placement: 'top',
|
||||
middleware: [offset(20), arrow({ element: arrowEl }), shift()],
|
||||
})
|
||||
.then(({ x, y, middlewareData }) => {
|
||||
panel.style.left = `${x}px`;
|
||||
panel.style.top = `${y}px`;
|
||||
|
||||
arrowEl.style.left = `${
|
||||
(middlewareData.arrow?.x ?? 0) - (middlewareData.shift?.x ?? 0)
|
||||
}px`;
|
||||
})
|
||||
.catch(e => {
|
||||
console.warn("Can't compute position", e);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { cards, _openedPanel } = this;
|
||||
const expanded = _openedPanel !== null;
|
||||
|
||||
return html`<edgeless-toolbar-button @click=${this._togglePanel}>
|
||||
<div class="template-cards ${expanded ? 'expanded' : ''}">
|
||||
<div class="arrow-icon">${ArrowDownSmallIcon}</div>
|
||||
${repeat(
|
||||
cards,
|
||||
(card, n) => html`
|
||||
<div
|
||||
class=${classMap({
|
||||
'template-card': true,
|
||||
[`card${n + 1}`]: true,
|
||||
})}
|
||||
>
|
||||
${card}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</edgeless-toolbar-button>`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _openedPanel: EdgelessTemplatePanel | null = null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export type Template = {
|
||||
/**
|
||||
* name of the sticker
|
||||
*
|
||||
* if not provided, it cannot be searched
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* template content
|
||||
*/
|
||||
content: unknown;
|
||||
|
||||
/**
|
||||
* external assets
|
||||
*/
|
||||
assets?: Record<string, string>;
|
||||
|
||||
preview?: string;
|
||||
|
||||
/**
|
||||
* type of template
|
||||
* `template`: normal template, looks like an article
|
||||
* `sticker`: sticker template, only contains one image block under surface block
|
||||
*/
|
||||
type: 'template' | 'sticker';
|
||||
};
|
||||
|
||||
export type TemplateCategory = {
|
||||
name: string;
|
||||
templates: Template[] | (() => Promise<Template[]>);
|
||||
};
|
||||
|
||||
export interface TemplateManager {
|
||||
list(category: string): Promise<Template[]> | Template[];
|
||||
|
||||
categories(): Promise<string[]> | string[];
|
||||
|
||||
search(keyword: string, category?: string): Promise<Template[]> | Template[];
|
||||
|
||||
extend?(manager: TemplateManager): void;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user